478 lines
17 KiB
TypeScript
478 lines
17 KiB
TypeScript
import type {
|
||
AcquisitionSlot,
|
||
AutoFillMode,
|
||
CalendarEntryState,
|
||
CoverageCell,
|
||
JsonObject,
|
||
Subscription,
|
||
SubscriptionCalendarEntry,
|
||
SubscriptionCoverage,
|
||
SubscriptionEffect,
|
||
SubscriptionGroup,
|
||
SubscriptionMediaKind,
|
||
SubscriptionOverviewItem,
|
||
SubscriptionResolution,
|
||
SubscriptionSlotSummary,
|
||
SubscriptionSourcePreset,
|
||
} from "@umt/contracts";
|
||
import type { StatusTone } from "~/components/console/status-badge.tsx";
|
||
|
||
// 订阅页(FE-5)的纯函数:分组/徽标/进度文案、矩阵格语义、表单 ↔ params。docs/design/subscriptions.md §1–§3。
|
||
|
||
export const SUB_KIND_LABEL: Record<SubscriptionMediaKind, string> = { series: "剧集", movie: "电影", variety: "综艺" };
|
||
|
||
/** 分组展示顺序(设计 §1.1:待定、追更中、待搜索、已完成、暂停) */
|
||
export const GROUP_ORDER: SubscriptionGroup[] = ["pending", "following", "waiting", "completed", "paused"];
|
||
export const GROUP_LABEL: Record<SubscriptionGroup, string> = {
|
||
following: "追更中",
|
||
waiting: "待搜索",
|
||
completed: "已完成",
|
||
paused: "暂停",
|
||
pending: "待定",
|
||
};
|
||
|
||
export type ToneLabel = { label: string; tone: StatusTone };
|
||
|
||
/** 进度文字:已入库/总;总未知为 "?" */
|
||
export function progressText(p: SubscriptionOverviewItem["progress"]): string {
|
||
return `${p.have}/${p.total ?? "?"}`;
|
||
}
|
||
|
||
/** 进度两段(已入库、在途)占比;总未知时按 已入库+在途 为分母只画相对比例 */
|
||
export function progressParts(p: SubscriptionOverviewItem["progress"]): { have: number; inflight: number } {
|
||
const denom = p.total ?? 0;
|
||
if (denom <= 0) return { have: 0, inflight: 0 };
|
||
return { have: Math.min(1, p.have / denom), inflight: Math.min(1 - Math.min(1, p.have / denom), p.inflight / denom) };
|
||
}
|
||
|
||
/** 季标签:S0 → 特别篇;接续后续季 → "S2 起";电影 / 全部季 → null */
|
||
export function seasonLabel(
|
||
x: Pick<SubscriptionOverviewItem, "mediaKind" | "seasonNumber" | "seasonScope">,
|
||
): string | null {
|
||
if (x.mediaKind === "movie") return null;
|
||
if (x.seasonNumber === null) return x.seasonScope === "following_seasons" ? "全部季" : null;
|
||
const base = x.seasonNumber === 0 ? "特别篇" : `S${x.seasonNumber}`;
|
||
return x.seasonScope === "following_seasons" && x.seasonNumber > 0 ? `${base} 起` : base;
|
||
}
|
||
|
||
/** 版本目标徽标:洗版中 → 在途 → 缺 N → 已齐;都没有 → 已入库 N */
|
||
export function slotBadge(s: SubscriptionSlotSummary): ToneLabel {
|
||
if (s.upgrading)
|
||
return {
|
||
label: s.minPri !== null && s.targetPri !== null ? `洗版中 ${s.minPri}→${s.targetPri}` : "洗版中",
|
||
tone: "warning",
|
||
};
|
||
if (s.inflight > 0) return { label: `在途 ${s.inflight}`, tone: "neutral" };
|
||
if (s.missing > 0) return { label: `缺 ${s.missing}`, tone: "destructive" };
|
||
if (s.total !== null && s.total > 0 && s.have >= s.total) return { label: "已齐", tone: "success" };
|
||
return { label: s.have > 0 ? `已入库 ${s.have}` : "未开始", tone: "neutral" };
|
||
}
|
||
|
||
/** 名称列表折叠:≤max 个名 + "+N";空 = emptyLabel */
|
||
export function foldNames(names: string[], max = 2, emptyLabel = "全部站点"): string {
|
||
if (names.length === 0) return emptyLabel;
|
||
const head = names.slice(0, max).join("、");
|
||
return names.length > max ? `${head} +${names.length - max}` : head;
|
||
}
|
||
|
||
export const AUTOFILL_LABEL: Record<AutoFillMode, string> = { inherit: "跟随规则组", on: "开", off: "关" };
|
||
export const AUTOFILL_SOURCE: Record<"subscription" | "rule_group" | "default", string> = {
|
||
subscription: "订阅设置",
|
||
rule_group: "规则组",
|
||
default: "系统缺省",
|
||
};
|
||
|
||
export function autoFillText(a: SubscriptionOverviewItem["autoFill"]): string {
|
||
return `自动补全${a.enabled ? "开" : "关"}${a.mode === "inherit" ? `(${AUTOFILL_SOURCE[a.source]})` : ""}`;
|
||
}
|
||
|
||
// ── 覆盖矩阵 ──
|
||
|
||
export type CoverageColumn = SubscriptionCoverage["seasons"][number]["columns"][number];
|
||
|
||
/** 各季在展平列里的起止下标 */
|
||
export function seasonSlices(cov: SubscriptionCoverage): { start: number; end: number }[] {
|
||
let i = 0;
|
||
return cov.seasons.map((s) => {
|
||
const start = i;
|
||
i += s.columns.length;
|
||
return { start, end: i };
|
||
});
|
||
}
|
||
|
||
export type CellKind =
|
||
| "have"
|
||
| "upgradable"
|
||
| "inflight"
|
||
| "deferred"
|
||
| "missing"
|
||
| "suspected"
|
||
| "not_aired"
|
||
| "skipped";
|
||
|
||
export function cellKind(c: CoverageCell): CellKind {
|
||
if (c.state === "have") return c.upgradable ? "upgradable" : "have";
|
||
if (c.state === "inflight") return "inflight";
|
||
if (c.state === "missing") {
|
||
if (c.deferred) return "deferred";
|
||
return c.reason === "suspected" ? "suspected" : "missing";
|
||
}
|
||
return c.reason === "not_aired" ? "not_aired" : "skipped";
|
||
}
|
||
|
||
export const CELL_LABEL: Record<CellKind, string> = {
|
||
have: "已入库",
|
||
upgradable: "可升级",
|
||
inflight: "在途",
|
||
deferred: "延后",
|
||
missing: "缺失",
|
||
suspected: "疑似缺失",
|
||
not_aired: "未播出",
|
||
skipped: "不需要",
|
||
};
|
||
|
||
export const PLAN_STAGE: Record<string, string> = { reserved: "已预留", downloading: "下载中", verifying: "核验中" };
|
||
|
||
export const SKIP_REASON: Record<string, string> = {
|
||
out_of_range: "集范围外",
|
||
not_aired: "未播出",
|
||
not_required: "该版本目标不要求这个内容版本",
|
||
suspected: "总集数未知,按已见最高集推断",
|
||
deferred: "有延后的资源",
|
||
};
|
||
|
||
/** 列头:E03 / 日期 / 电影内容版本 */
|
||
export function columnLabel(col: CoverageColumn): string {
|
||
if (col.edition) return col.edition === "default" ? "正片" : col.edition;
|
||
if (col.episode !== null) return `E${String(col.episode).padStart(2, "0")}`;
|
||
if (col.airDate) return col.airDate.slice(5);
|
||
return col.unitKey;
|
||
}
|
||
|
||
export function seasonTitle(s: SubscriptionCoverage["seasons"][number], kind: SubscriptionMediaKind): string {
|
||
if (kind === "movie") return "电影";
|
||
if (s.seasonNumber === null) return "按播出日期";
|
||
return s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`;
|
||
}
|
||
|
||
// ── 版本目标 ──
|
||
|
||
export type UpgradeStrategy = "off" | "per_unit" | "full_pack" | "movie";
|
||
export const UPGRADE_LABEL: Record<UpgradeStrategy, string> = {
|
||
off: "不洗版",
|
||
per_unit: "按集洗版",
|
||
full_pack: "全集洗版",
|
||
movie: "洗版",
|
||
};
|
||
|
||
/** 订阅版本目标当前的升级策略:任一开启取其 mode */
|
||
export function upgradeOf(
|
||
slots: Pick<AcquisitionSlot, "upgradeThreshold">[],
|
||
kind: SubscriptionMediaKind,
|
||
): UpgradeStrategy {
|
||
const on = slots.find((s) => (s.upgradeThreshold as { enabled?: unknown }).enabled === true);
|
||
if (!on) return "off";
|
||
if (kind === "movie") return "movie";
|
||
return (on.upgradeThreshold as { mode?: unknown }).mode === "full_pack" ? "full_pack" : "per_unit";
|
||
}
|
||
|
||
/** 把 from 移到 to(拖动排序) */
|
||
export function moveItem<T>(arr: readonly T[], from: number, to: number): T[] {
|
||
const out = arr.slice();
|
||
if (from < 0 || from >= out.length || to < 0 || to >= out.length || from === to) return out;
|
||
const [x] = out.splice(from, 1);
|
||
out.splice(to, 0, x as T);
|
||
return out;
|
||
}
|
||
|
||
// ── 表单 ──
|
||
|
||
export const RESOLUTION_LABEL: Record<SubscriptionResolution, string> = {
|
||
"4k": "4K",
|
||
"1080p": "1080p",
|
||
"720p": "720p",
|
||
};
|
||
export const SOURCE_PRESET_LABEL: Record<SubscriptionSourcePreset, string> = {
|
||
bluray_disc: "蓝光原盘",
|
||
remux: "Remux",
|
||
bluray: "BluRay",
|
||
uhd: "UHD",
|
||
web: "WEB-DL",
|
||
hdtv: "HDTV",
|
||
h265: "H.265",
|
||
h264: "H.264",
|
||
};
|
||
export const EFFECT_LABEL: Record<SubscriptionEffect, string> = {
|
||
dv: "杜比视界",
|
||
hdr: "HDR",
|
||
sdr: "SDR",
|
||
atmos: "杜比全景声",
|
||
};
|
||
const oneOf = <K extends string>(v: unknown, labels: Record<K, string>): K | "" =>
|
||
typeof v === "string" && v in labels ? (v as K) : "";
|
||
|
||
export type SubscriptionDraft = {
|
||
mediaKind: SubscriptionMediaKind;
|
||
seasonUnitId: string;
|
||
seasonScope: Subscription["seasonScope"];
|
||
mode: Subscription["mode"];
|
||
priority: Subscription["priority"];
|
||
episodeStart: string;
|
||
episodeEnd: string;
|
||
total: string;
|
||
totalLocked: boolean;
|
||
siteIds: string[];
|
||
downloaderInstanceId: string;
|
||
libraryRootId: string;
|
||
downloadSavePath: string;
|
||
policyRevisionId: string;
|
||
upgrade: UpgradeStrategy;
|
||
ruleGroupIds: string[];
|
||
include: string;
|
||
exclude: string;
|
||
keywords: string;
|
||
/** MP 分辨率 / 资源质量 / 特效预设(写到该订阅自己的版本目标) */
|
||
resolution: SubscriptionResolution | "";
|
||
source: SubscriptionSourcePreset | "";
|
||
effect: SubscriptionEffect | "";
|
||
searchIntervalHours: string;
|
||
stopAfter: string;
|
||
autoFill: AutoFillMode;
|
||
unitsPerDay: string;
|
||
gbPerDay: string;
|
||
};
|
||
|
||
const lines = (s: string) =>
|
||
s
|
||
.split(/\n/)
|
||
.map((x) => x.trim())
|
||
.filter(Boolean);
|
||
const str = (v: unknown) => (typeof v === "string" ? v : "");
|
||
const numStr = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? String(v) : "");
|
||
const strArr = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
|
||
|
||
export function emptyDraft(kind: SubscriptionMediaKind = "series"): SubscriptionDraft {
|
||
return {
|
||
mediaKind: kind,
|
||
seasonUnitId: "",
|
||
seasonScope: kind === "movie" ? "current_season" : "following_seasons",
|
||
mode: kind === "movie" ? "once" : "continuous",
|
||
priority: "tracking",
|
||
episodeStart: "",
|
||
episodeEnd: "",
|
||
total: "",
|
||
totalLocked: false,
|
||
siteIds: [],
|
||
downloaderInstanceId: "",
|
||
libraryRootId: "",
|
||
downloadSavePath: "",
|
||
policyRevisionId: "",
|
||
upgrade: "off",
|
||
ruleGroupIds: [],
|
||
include: "",
|
||
exclude: "",
|
||
keywords: "",
|
||
resolution: "",
|
||
source: "",
|
||
effect: "",
|
||
searchIntervalHours: "",
|
||
stopAfter: "",
|
||
autoFill: "inherit",
|
||
unitsPerDay: "",
|
||
gbPerDay: "",
|
||
};
|
||
}
|
||
|
||
const GB = 1024 ** 3;
|
||
|
||
/** datetime-local 值 ↔ ISO */
|
||
export function toLocalInput(iso: string): string {
|
||
const t = new Date(iso);
|
||
if (Number.isNaN(t.getTime())) return "";
|
||
const p = (n: number) => String(n).padStart(2, "0");
|
||
return `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}T${p(t.getHours())}:${p(t.getMinutes())}`;
|
||
}
|
||
|
||
export function draftFromSubscription(sub: Subscription, upgrade: UpgradeStrategy): SubscriptionDraft {
|
||
const p = sub.params as Record<string, unknown>;
|
||
const range = (p.episodeRange ?? {}) as { start?: unknown; end?: unknown };
|
||
const budget = (p.fillBudget ?? {}) as { unitsPerDay?: unknown; bytesPerDay?: unknown };
|
||
const bytes = typeof budget.bytesPerDay === "number" ? budget.bytesPerDay : null;
|
||
return {
|
||
...emptyDraft(sub.mediaKind),
|
||
mediaKind: sub.mediaKind,
|
||
seasonUnitId: sub.seasonUnitId ?? "",
|
||
seasonScope: sub.seasonScope,
|
||
mode: sub.mode,
|
||
priority: sub.priority,
|
||
episodeStart: numStr(range.start),
|
||
episodeEnd: numStr(range.end),
|
||
siteIds: strArr(p.siteIds),
|
||
downloaderInstanceId: str(p.downloaderInstanceId),
|
||
libraryRootId: str(p.libraryRootId),
|
||
downloadSavePath: str(p.downloadSavePath),
|
||
policyRevisionId: sub.policyRevisionId ?? "",
|
||
upgrade,
|
||
ruleGroupIds: strArr(p.ruleGroupIds),
|
||
include: strArr(p.include).join("\n"),
|
||
exclude: strArr(p.exclude).join("\n"),
|
||
keywords: strArr(p.keywords).join("\n"),
|
||
resolution: oneOf(p.resolution, RESOLUTION_LABEL),
|
||
source: oneOf(p.source, SOURCE_PRESET_LABEL),
|
||
effect: oneOf(p.effect, EFFECT_LABEL),
|
||
searchIntervalHours: numStr(p.searchIntervalHours),
|
||
stopAfter: typeof p.stopAfter === "string" ? toLocalInput(p.stopAfter) : "",
|
||
autoFill: p.autoFill === "on" || p.autoFill === "off" ? p.autoFill : "inherit",
|
||
unitsPerDay: numStr(budget.unitsPerDay),
|
||
gbPerDay: bytes !== null ? String(Math.round((bytes / GB) * 10) / 10) : "",
|
||
};
|
||
}
|
||
|
||
export type DraftErrors = Partial<Record<keyof SubscriptionDraft, string>>;
|
||
|
||
const isInt = (s: string) => /^\d+$/.test(s.trim());
|
||
|
||
/**
|
||
* 草稿 → params(PATCH 整体替换,所以以现有 params 为底保留未知键)。
|
||
* upgradeChanged:只有升级策略改了才带 best_version(否则每次保存都会 bump 版本目标 revision)。
|
||
*/
|
||
export function draftToParams(
|
||
d: SubscriptionDraft,
|
||
base: JsonObject = {},
|
||
opts: { upgradeChanged: boolean; totalChanged: boolean },
|
||
): { params: JsonObject; errors: DraftErrors } {
|
||
const errors: DraftErrors = {};
|
||
const out: Record<string, unknown> = { ...base };
|
||
const known = [
|
||
"siteIds",
|
||
"episodeRange",
|
||
"searchIntervalHours",
|
||
"stopAfter",
|
||
"downloaderInstanceId",
|
||
"ruleGroupIds",
|
||
"keywords",
|
||
"include",
|
||
"exclude",
|
||
"libraryRootId",
|
||
"downloadSavePath",
|
||
"autoFill",
|
||
"fillBudget",
|
||
"resolution",
|
||
"source",
|
||
"effect",
|
||
];
|
||
for (const k of known) delete out[k];
|
||
const set = (k: string, v: unknown) => {
|
||
if (v === undefined || v === "" || (Array.isArray(v) && v.length === 0)) return;
|
||
out[k] = v;
|
||
};
|
||
set("siteIds", d.siteIds);
|
||
set("downloaderInstanceId", d.downloaderInstanceId);
|
||
set("libraryRootId", d.libraryRootId);
|
||
set("downloadSavePath", d.downloadSavePath.trim());
|
||
set("ruleGroupIds", d.ruleGroupIds);
|
||
set("include", lines(d.include));
|
||
set("exclude", lines(d.exclude));
|
||
const kw = lines(d.keywords);
|
||
if (kw.length > 10) errors.keywords = "最多 10 个";
|
||
set("keywords", kw);
|
||
set("resolution", d.resolution);
|
||
set("source", d.source);
|
||
set("effect", d.effect);
|
||
if (d.mediaKind !== "movie") {
|
||
const s = d.episodeStart.trim();
|
||
const e = d.episodeEnd.trim();
|
||
if (s && (!isInt(s) || Number(s) < 1)) errors.episodeStart = "填正整数";
|
||
if (e && (!isInt(e) || Number(e) < 1)) errors.episodeEnd = "填正整数";
|
||
if (s && e && !errors.episodeStart && !errors.episodeEnd && Number(e) < Number(s))
|
||
errors.episodeEnd = "结束集不能小于开始集";
|
||
if ((s || e) && !errors.episodeStart && !errors.episodeEnd)
|
||
out.episodeRange = { ...(s ? { start: Number(s) } : {}), ...(e ? { end: Number(e) } : {}) };
|
||
const t = d.total.trim();
|
||
if (t) {
|
||
if (!isInt(t) || Number(t) < 1) errors.total = "填正整数";
|
||
else if (opts.totalChanged) {
|
||
out.total_episode = Number(t);
|
||
out.manual_total_episode = d.totalLocked;
|
||
}
|
||
}
|
||
}
|
||
const h = d.searchIntervalHours.trim();
|
||
if (h) {
|
||
if (!/^\d+(\.\d+)?$/.test(h) || Number(h) > 720) errors.searchIntervalHours = "0–720 小时";
|
||
else out.searchIntervalHours = Number(h);
|
||
}
|
||
if (d.stopAfter) {
|
||
const t = new Date(d.stopAfter);
|
||
if (Number.isNaN(t.getTime())) errors.stopAfter = "时间无效";
|
||
else out.stopAfter = t.toISOString();
|
||
}
|
||
if (d.autoFill !== "inherit") out.autoFill = d.autoFill;
|
||
const budget: Record<string, number> = {};
|
||
if (d.unitsPerDay.trim()) {
|
||
if (!isInt(d.unitsPerDay)) errors.unitsPerDay = "填非负整数";
|
||
else budget.unitsPerDay = Number(d.unitsPerDay);
|
||
}
|
||
if (d.gbPerDay.trim()) {
|
||
const g = Number(d.gbPerDay);
|
||
if (!Number.isFinite(g) || g < 0) errors.gbPerDay = "填非负数";
|
||
else budget.bytesPerDay = Math.round(g * GB);
|
||
}
|
||
if (Object.keys(budget).length) out.fillBudget = budget;
|
||
if (opts.upgradeChanged) {
|
||
out.best_version = d.upgrade !== "off";
|
||
if (d.mediaKind !== "movie") out.best_version_full = d.upgrade === "full_pack";
|
||
}
|
||
return { params: out as JsonObject, errors };
|
||
}
|
||
|
||
// ── 日历(设计 §4)──
|
||
|
||
export const CALENDAR_STATE_LABEL: Record<CalendarEntryState, string> = {
|
||
have: "已入库",
|
||
partial: "部分入库",
|
||
missing: "未入库",
|
||
upcoming: "未播出",
|
||
};
|
||
|
||
const ymd = (d: Date) =>
|
||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||
|
||
/** "YYYY-MM" → 该月第一天;无效时取今天所在月 */
|
||
export function monthStart(month: string | undefined, today = new Date()): Date {
|
||
const m = /^(\d{4})-(\d{2})$/.exec(month ?? "");
|
||
if (m) {
|
||
const d = new Date(Number(m[1]), Number(m[2]) - 1, 1);
|
||
if (!Number.isNaN(d.getTime())) return d;
|
||
}
|
||
return new Date(today.getFullYear(), today.getMonth(), 1);
|
||
}
|
||
|
||
export const monthKey = (d: Date) => ymd(d).slice(0, 7);
|
||
|
||
export function shiftMonth(d: Date, n: number): Date {
|
||
return new Date(d.getFullYear(), d.getMonth() + n, 1);
|
||
}
|
||
|
||
/** 月视图网格:从该月 1 日所在周的周一起,整周铺满(5 或 6 周) */
|
||
export function monthGrid(first: Date): { date: string; inMonth: boolean }[] {
|
||
const start = new Date(first);
|
||
start.setDate(1 - ((first.getDay() + 6) % 7));
|
||
const last = new Date(first.getFullYear(), first.getMonth() + 1, 0);
|
||
const days: { date: string; inMonth: boolean }[] = [];
|
||
for (let d = new Date(start); days.length < 42; d.setDate(d.getDate() + 1)) {
|
||
if (d > last && days.length % 7 === 0) break;
|
||
days.push({ date: ymd(d), inMonth: d.getMonth() === first.getMonth() });
|
||
}
|
||
return days;
|
||
}
|
||
|
||
/** 按日期分桶 */
|
||
export function entriesByDate(entries: readonly SubscriptionCalendarEntry[]): Map<string, SubscriptionCalendarEntry[]> {
|
||
const m = new Map<string, SubscriptionCalendarEntry[]>();
|
||
for (const e of entries) m.set(e.date, [...(m.get(e.date) ?? []), e]);
|
||
return m;
|
||
}
|
||
|
||
export const todayKey = (now = new Date()) => ymd(now);
|