Files
UHD_Media_Tool/apps/web/app/components/subscriptions/model.ts
T
rootandClaude Opus 5.5 550ac285f4 feat(copy): 处理状态的「入库」改为「整理」,术语表与 check:copy 同步
真正入库取决于媒体服务器有没有扫到,本工具只负责整理(元数据、NFO 与图片、按规则归到目录)。
界面与面向用户的服务端文案里「已入库 / 未入库 / 部分入库 / 入库失败 / 入库中 / 入库后 / 原地入库…」
改为「已整理 / 未整理 / 部分整理 / 整理失败 / 整理中 / 整理后 / 原地整理…」;文件管理快速筛选「已整理」、
目录副行「n 个已整理 / 未整理」、季卡片「已整理 x/y」。代码标识符不改,「Emby 已有」这类媒体服务器状态保留。
glossary 加「整理」口径条目;check:copy 拦处理状态语境的「入库」搭配,其它线待替换的文件暂列 TERM_PENDING。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 17:54:28 +08:00

478 lines
17 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 {
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);