Files
UHD_Media_Tool/apps/web/app/components/subscriptions/model.ts
T

407 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
AutoFillMode,
CalendarEntryState,
CoverageCell,
JsonObject,
Subscription,
SubscriptionCalendarEntry,
SubscriptionCoverage,
SubscriptionGroup,
SubscriptionMediaKind,
SubscriptionOverviewItem,
SubscriptionSlotSummary,
} 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} 季`;
}
/** 把 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 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;
include: string;
exclude: string;
keywords: string;
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: "",
include: "",
exclude: "",
keywords: "",
searchIntervalHours: "",
stopAfter: "",
autoFill: "inherit",
unitsPerDay: "",
gbPerDay: "",
};
}
const GB = 1024 ** 3;
const gbStr = (v: unknown) => (typeof v === "number" ? String(Math.round((v / GB) * 10) / 10) : "");
/** 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): 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 };
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 ?? "",
include: strArr(p.include).join("\n"),
exclude: strArr(p.exclude).join("\n"),
keywords: strArr(p.keywords).join("\n"),
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: gbStr(budget.bytesPerDay),
};
}
export type DraftErrors = Partial<Record<keyof SubscriptionDraft, string>>;
const isInt = (s: string) => /^\d+$/.test(s.trim());
/**
* 草稿 → params(PATCH 整体替换,所以以现有 params 为底保留未知键)。
* 收录规则只在版本编辑器维护;旧导入的质量预设与规则组原样保留,不从普通表单重建。
*/
export function draftToParams(
d: SubscriptionDraft,
base: JsonObject = {},
opts: { totalChanged: boolean; autoFillChanged: boolean; clearQualityPreset?: boolean },
): { params: JsonObject; errors: DraftErrors } {
const errors: DraftErrors = {};
const out: Record<string, unknown> = { ...base };
if (opts.clearQualityPreset) for (const k of ["resolution", "source", "effect"]) delete out[k];
const known = [
"siteIds",
"episodeRange",
"searchIntervalHours",
"stopAfter",
"downloaderInstanceId",
"keywords",
"include",
"exclude",
"libraryRootId",
"downloadSavePath",
];
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("include", lines(d.include));
set("exclude", lines(d.exclude));
const kw = lines(d.keywords);
if (kw.length > 10) errors.keywords = "最多 10 个";
set("keywords", kw);
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 (opts.autoFillChanged) {
delete out.autoFill;
if (d.autoFill !== "inherit") out.autoFill = d.autoFill;
const original = (base.fillBudget ?? {}) as Record<string, unknown>;
const budget = { ...original };
if (d.unitsPerDay.trim() && !isInt(d.unitsPerDay)) errors.unitsPerDay = "填非负整数";
if (d.unitsPerDay !== numStr(original.unitsPerDay)) {
delete budget.unitsPerDay;
if (d.unitsPerDay.trim() && !errors.unitsPerDay) budget.unitsPerDay = Number(d.unitsPerDay);
}
if (d.gbPerDay.trim() && (!Number.isFinite(Number(d.gbPerDay)) || Number(d.gbPerDay) < 0))
errors.gbPerDay = "填非负数";
if (d.gbPerDay !== gbStr(original.bytesPerDay)) {
delete budget.bytesPerDay;
if (d.gbPerDay.trim() && !errors.gbPerDay) budget.bytesPerDay = Math.round(Number(d.gbPerDay) * GB);
}
delete out.fillBudget;
if (Object.keys(budget).length) out.fillBudget = budget;
}
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);