feat(subscriptions): private rule groups, MP quality presets, save path, reset and calendar API

- include/exclude sync to a per-subscription private rule group (rule_group.hard, migration 0036);
  evaluation merges chain hard conditions; private groups hidden from GET /rules, soft-deleted with
  the subscription and rebuilt on resubscribe
- MP resolution / quality / effect map to presets written onto the subscription's own slots
  (default slot template on create); UHD / Atmos go to the private group include
- downloadSavePath mapped to the downloader path and passed as qB savepath; libraryRootId used
  for the first organize when the work has no path binding; keywords appended to active search
- POST /subscriptions/{id}/reset releases stale in-flight plans, clears deferrals, re-evaluates
- GET /subscriptions/calendar from episode air dates; refreshMetadata writes TMDB season
  episode air dates for tracked seasons

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 20:14:40 +08:00
1 parent 52402864d6
commit 70d56645fe
20 files changed
+1663 -44

No files matched your search

@@ -330,7 +330,8 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
app,
Api.rules.list,
async (_c, { query }) => {
let q = db().selectFrom("rule_group").selectAll();
// 订阅私有组(订阅表单的包含 / 排除)不在规则页列出,按订阅查看
let q = db().selectFrom("rule_group").selectAll().where(sql<boolean>`not (scope ? 'subscriptionId')`);
if (query.includeDeleted !== "true") q = q.where("deleted_at", "is", null);
if (query.kind) q = q.where("kind", "=", query.kind);
if (query.source) q = q.where("source", "=", query.source);
@@ -1,11 +1,13 @@
import {
type AcquisitionSlot,
Api,
type SlotTemplate,
type Subscription,
type SubscriptionGroup,
type SubscriptionHistory,
type SubscriptionOverviewItem,
type SubscriptionOverviewPage,
type SubscriptionParams,
type SubscriptionSort,
} from "@umt/contracts";
import type { SubscriptionRow } from "@umt/db";
@@ -27,6 +29,8 @@ import {
subscriptionCoverage,
} from "../services/coverage.ts";
import { loadRuleCatalog } from "../services/rules.ts";
import { CALENDAR_MAX_DAYS, subscriptionCalendar } from "../services/subscription-calendar.ts";
import { resetSubscription } from "../services/subscription-reset.ts";
import {
applyEpisodeTotal,
enqueueSubscriptionSearch,
@@ -35,9 +39,11 @@ import {
TRACKING_SUB_STATUSES,
} from "../services/subscriptions.ts";
import {
applySlotPreset,
applySlotUpgrade,
type NormalizedParams,
normalizeSubscriptionParams,
syncPrivateRuleGroup,
} from "../services/subscriptions-params.ts";
import { actorOf, conflict, errMessage, jsonb, notFound, toApi, toApiList } from "../util.ts";
import { mount, paginate } from "./helpers.ts";
@@ -100,8 +106,22 @@ async function assertNoDuplicate(
if (hit) conflict(`该作品同一季已有订阅:${hit.id}`);
}
/** 保存后应用表单的派生写入:MP total_episode → 季总集数;best_version → 版本目标升级策略 */
async function applyDerived(exec: Exec, sub: SubscriptionRow, n: NormalizedParams, now: Date) {
/** 没有收录策略模板、表单给了质量预设时,新订阅按这个模板建一个版本目标(预设随后写入) */
const DEFAULT_SLOT_TEMPLATE: SlotTemplate = { name: "默认版本" };
/**
* 保存后应用表单的派生写入:包含 / 排除 → 订阅私有规则组;质量预设(MP resolution / quality / effect)→
* 该订阅自己的版本目标;MP total_episode → 季总集数;best_version → 版本目标升级策略。
*/
async function applyDerived(
exec: Exec,
sub: SubscriptionRow,
n: NormalizedParams,
now: Date,
prev: SubscriptionParams = {},
) {
await syncPrivateRuleGroup(exec, sub, now);
await applySlotPreset(exec, { subscriptionId: sub.id, prev, next: n.params });
if (!sub.media_entity_id) return;
if (n.episodeTotal) {
const season = sub.season_unit_id
@@ -296,6 +316,20 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
},
{ tag: TAG },
);
mount(
app,
Api.subscriptions.calendar,
async (_c, { query }) => {
const from = Date.parse(`${query.from}T00:00:00Z`);
const to = Date.parse(`${query.to}T00:00:00Z`);
if (Number.isNaN(from) || Number.isNaN(to)) throw new ApiException("bad_request", "from / to 不是有效日期");
if (to < from) throw new ApiException("bad_request", "to 不能早于 from");
if ((to - from) / 86_400_000 + 1 > CALENDAR_MAX_DAYS)
throw new ApiException("bad_request", `日历跨度最多 ${CALENDAR_MAX_DAYS} 天`);
return subscriptionCalendar(db(), query, d.ports.now());
},
{ tag: TAG },
);
mount(app, Api.subscriptions.get, async (_c, { params }) => toApi<Subscription>(await load(params.id)), {
tag: TAG,
});
@@ -352,14 +386,20 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
const pol =
(body.policyRevisionId ? pols.find((p) => p.row.id === body.policyRevisionId) : undefined) ??
pols.find((p) => p.rules.defaultSlots?.length);
if (pol?.rules.defaultSlots?.length) {
// 策略没有模板但表单给了质量预设(MP resolution / quality / effect):建一个"默认版本"承载预设
const preset = !!(norm.params.resolution || norm.params.source || norm.params.effect);
const templates = pol?.rules.defaultSlots?.length
? pol.rules.defaultSlots
: preset
? [DEFAULT_SLOT_TEMPLATE]
: [];
if (templates.length)
await createSlotsFromTemplates(trx, {
entityId: r.media_entity_id,
subscriptionId: r.id,
policyRevisionId: pol.row.id,
templates: pol.rules.defaultSlots,
policyRevisionId: pol?.rules.defaultSlots?.length ? pol.row.id : null,
templates,
});
}
}
}
await applyDerived(trx, r, norm, now);
@@ -417,7 +457,8 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
.where("id", "=", params.id)
.returningAll()
.executeTakeFirstOrThrow();
if (norm) await applyDerived(trx, r, norm, d.ports.now());
if (norm) await applyDerived(trx, r, norm, d.ports.now(), subParams(cur));
else if (body.status !== undefined) await syncPrivateRuleGroup(trx, r, d.ports.now());
return r;
});
invalidateCoverage(r.id);
@@ -448,6 +489,8 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
.where("id", "=", sub.id)
.returningAll()
.executeTakeFirstOrThrow();
// 私有规则组随订阅删除(取消),重新订阅时按 params 重建
if (to === "cancelled" || sub.status === "cancelled") await syncPrivateRuleGroup(db(), r, now);
invalidateCoverage(sub.id);
return r;
};
@@ -538,6 +581,24 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
{ tag: TAG, status: 200 },
);
mount(
app,
Api.subscriptions.reset,
async (c, { params }) => {
const sub = await load(params.id);
if (sub.status === "completed" || sub.status === "cancelled")
conflict(`订阅状态为 ${sub.status},用「重新订阅」`);
const r = await resetSubscription(d, sub);
await audit(db(), c, "reset", sub.id, {
releasedPlans: r.releasedPlans,
keptPlans: r.keptPlans,
clearedDeferrals: r.clearedDeferrals,
});
return r;
},
{ tag: TAG, status: 200 },
);
// ── 搜索 / 进度 / 覆盖矩阵 ──
mount(app, Api.subscriptions.search, async (_c, { params }) => search(await load(params.id)), { tag: TAG });
mount(
@@ -31,9 +31,19 @@ import {
} from "@umt/domain";
import { fromParsedColumns, slotSpecFromRow, toContractSlotEvaluation } from "@umt/mapping";
import { hashJson, jsonb, startOfDay, toApi } from "../util.ts";
import { type AoDeps, automationBlock, type Exec, enqueueTracked, loadInstances, recordCandidate } from "./common.ts";
import {
type AoDeps,
automationBlock,
downloaderPathOf,
type Exec,
enqueueTracked,
hostPathOf,
type InstanceTopology,
loadInstances,
recordCandidate,
} from "./common.ts";
import { autoFillCheck, type ForceFill } from "./evaluate-autofill.ts";
import { entityOriginalLanguage, loadRuleCatalog, type RuleCatalog, resolveRuleChain } from "./rules.ts";
import { entityOriginalLanguage, loadRuleCatalog, mergeHard, type RuleCatalog, resolveRuleChain } from "./rules.ts";
import {
clearDeferral,
rangeOf,
@@ -45,7 +55,6 @@ import {
subParams,
TRACKING_SUB_STATUSES,
} from "./subscriptions.ts";
import { withSubscriptionFilters } from "./subscriptions-params.ts";
import { decideVolume, loadVolumes } from "./volumes.ts";
// evaluateAcquisition:release.resolved → 匹配订阅/Slot → 模式 → 确定性评估 → 预算/缓存卷 → 冻结计划并预留。
@@ -545,7 +554,7 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
await note(c, { ...base, decision: "skipped", mode: null, rejected: [], reasons: ["site_out_of_scope"] });
return done;
}
const spec = withSubscriptionFilters(slotSpecFromRow(toApi<AcquisitionSlot>(slot)), params);
const spec = slotSpecFromRow(toApi<AcquisitionSlot>(slot));
const ctx = contextOf(rel, now);
// 规则组:版本目标 ruleGroupIds → 订阅 ruleGroupIds → 订阅默认链;开升级叠加洗版默认链
c.rules ??= loadRuleCatalog(db);
@@ -559,6 +568,8 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
siteId: rel.site_id,
subscriptionId: sub?.id,
});
// 规则组的结构化硬条件(订阅私有组的包含 / 排除)并入版本目标硬条件
spec.hard = mergeHard(spec.hard, chain.hard);
if (chain.groups.length) {
spec.rules = { groups: chain.groups, atoms: chain.atoms };
c.langs ??= new Map();
@@ -882,10 +893,28 @@ async function gates(
const { d } = c;
const blocked = await automationBlock(d);
if (blocked) return { ok: false, reasons: [blocked], rejected: [] };
const wanted = (sub?.params as { downloaderInstanceId?: string } | null)?.downloaderInstanceId;
const sp = (sub?.params ?? {}) as { downloaderInstanceId?: string; downloadSavePath?: string };
const wanted = sp.downloaderInstanceId;
const insts = (await loadInstances(d.ctx.db)).filter((t) => t.savePath && (!wanted || t.inst.id === wanted));
const inst = insts[0];
if (!inst?.savePath) return { ok: false, reasons: ["no_downloader_instance"], rejected: [] };
// 订阅指定的保存路径:本机路径经下载器路径映射换成下载器侧路径;本来就是下载器侧路径的原样用
let savePath = inst.savePath;
if (sp.downloadSavePath) {
const mapped = subscriptionSavePath(inst, sp.downloadSavePath);
if (!mapped)
return {
ok: false,
reasons: ["download_save_path_unmapped"],
rejected: [
{
code: "download_save_path_unmapped",
message: `保存路径 ${sp.downloadSavePath} 不在下载器 ${inst.inst.name} 的路径映射里`,
},
],
};
savePath = mapped;
}
const budget = await d.ctx.settings.get("budget");
const b = checkBudget(
@@ -925,7 +954,19 @@ async function gates(
],
};
}
return { ok: true, instanceId: inst.inst.id, volumeKey: inst.volumeKey, savePath: inst.savePath };
return { ok: true, instanceId: inst.inst.id, volumeKey: inst.volumeKey, savePath };
}
/**
* 订阅保存路径 → 下载器侧路径(qB savepath):先按本机路径经映射换算;不行再看它是否已是下载器侧路径
* (能反向映射回本机);实例没配映射视为同机同路径。都不成立返回 null(不猜)。
*/
export function subscriptionSavePath(t: Pick<InstanceTopology, "inst" | "mappings">, path: string): string | null {
const p = path.replace(/\/+$/, "") || "/";
if (t.mappings.length === 0) return p;
const asHost = downloaderPathOf(t, p);
if (asHost) return asHost;
return hostPathOf(t, p) ? p : null;
}
async function reserve(
@@ -220,7 +220,7 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
if (plan.status !== "reserved") return { status: "skipped", reason: `plan_${plan.status}` };
const blocked = await automationBlock(d);
if (blocked) return { status: "deferred", reason: blocked };
const ev = (plan.evaluation ?? {}) as { instanceId?: string; volumeKey?: string };
const ev = (plan.evaluation ?? {}) as { instanceId?: string; volumeKey?: string; savePath?: string };
const topo = (await loadInstances(db)).find((t) => t.inst.id === ev.instanceId);
if (!topo?.savePath) return failPlan(d, plan.id, plan.slot_id, "downloader_unavailable");
@@ -239,7 +239,8 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
const site = await db.selectFrom("site").selectAll().where("id", "=", rel.site_id).executeTakeFirst();
const client = await d.ports.downloader(topo.inst);
const tag = topo.inst.managed_tag;
const savePath = topo.savePath;
// 评估时定下的保存路径(订阅 downloadSavePath 经路径映射后的下载器侧路径),没有用实例默认
const savePath = ev.savePath || topo.savePath;
const view = (t: QbTorrent) => ({
hash: t.hash,
@@ -45,7 +45,7 @@ import { effectiveNaming, ensureNamingVersion, loadNamingEntity, namingByVersion
import { PathGuardError } from "./organize-guard.ts";
import { type FrozenTransfer, finalizeTransfer, planTransfer, type TransferSpec } from "./organize-transfer.ts";
import { hnrSatisfied } from "./retention.ts";
import { slotComplete as sharedSlotComplete } from "./subscriptions.ts";
import { slotComplete as sharedSlotComplete, subscriptionLibraryRoot } from "./subscriptions.ts";
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
@@ -247,11 +247,15 @@ async function chooseRoot(
entity: MediaEntityRow,
bindings: DomainBinding[],
wanted?: string,
/** 订阅指定的库根:只在作品还没有路径绑定时用(首次整理建绑定),不存在/停用时按类型默认 */
preferred?: string | null,
): Promise<LibraryRootRow | null> {
const roots = await exec.selectFrom("library_root").selectAll().where("enabled", "=", true).execute();
if (wanted) return roots.find((r) => r.id === wanted) ?? null;
const bound = bindings.find((b) => b.active !== false);
if (bound) return roots.find((r) => r.id === bound.libraryRootId) ?? null;
const pref = preferred ? roots.find((r) => r.id === preferred) : undefined;
if (pref) return pref;
const kinds = libraryKinds(entity.kind);
return (
roots
@@ -426,7 +430,13 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
.where("status", "=", "active")
.execute();
const bindings = bindingRows.map((r) => toDomainPathBinding(toApi<PathBinding>(r)));
const root = await chooseRoot(db, entity, bindings, req.targetLibraryRootId);
const root = await chooseRoot(
db,
entity,
bindings,
req.targetLibraryRootId,
await subscriptionLibraryRoot(db, { slotId, entityId: entity.id }),
);
if (!root) return fail("no_library_root", {}, entity.id);
const targetDir = req.targetDir?.replace(/\/+$/, "");
if (targetDir !== undefined) {
@@ -25,6 +25,7 @@ import {
type RuleGroupSpec,
type RuleLayer,
type RuleParseError,
type SlotHardConditions,
type SlotRuleChain,
serializeExpr,
serializeLayers,
@@ -144,7 +145,10 @@ export interface ResolveInput extends RuleTarget {
* 引用链:订阅私有组 → (版本目标 ruleGroupIds || 订阅 ruleGroupIds || 该类默认链)→ 开升级时叠加洗版默认链。
* 按 scope 过滤、跳过停用/已删;pri 取链上最后一个带层的组(对齐 MP 多组串联)。
*/
export function resolveRuleChain(cat: RuleCatalog, input: ResolveInput): SlotRuleChain & { ids: string[] } {
export function resolveRuleChain(
cat: RuleCatalog,
input: ResolveInput,
): SlotRuleChain & { ids: string[]; hard: SlotHardConditions } {
const explicit = input.slotRuleGroupIds?.length ? input.slotRuleGroupIds : (input.subRuleGroupIds ?? []);
const ids: string[] = [];
const push = (id: string) => {
@@ -158,7 +162,41 @@ export function resolveRuleChain(cat: RuleCatalog, input: ResolveInput): SlotRul
const rows = ids
.map((id) => cat.groups.get(id))
.filter((g): g is RuleGroupRow => !!g && g.enabled && inScope(g, input));
return { ids: rows.map((r) => r.id), groups: rows.map(groupSpecOf), atoms: cat.atoms };
return { ids: rows.map((r) => r.id), groups: rows.map(groupSpecOf), atoms: cat.atoms, hard: chainHard(rows) };
}
/**
* 几组"至少命中一条"的正则取 AND:只有一组时原样返回;多组时合成一条前瞻正则
* `^(?=[\s\S]*(?:a1|a2))(?=[\s\S]*(?:b1|b2))`(evaluate 的关键词匹配整体仍是"至少命中一条")。
*/
export function allOfAny(lists: readonly (readonly string[])[]): string[] {
const xs = lists.filter((l) => l.length > 0);
if (xs.length === 0) return [];
if (xs.length === 1) return [...xs[0]!];
return [`^${xs.map((l) => `(?=[\\s\\S]*(?:${l.join("|")}))`).join("")}`];
}
/** 规则组的结构化硬条件(rule_group.hard:include / exclude 正则)按链合并:各组 include 都要满足,exclude 取并集 */
export function chainHard(rows: readonly Pick<RuleGroupRow, "hard">[]): SlotHardConditions {
const include: string[][] = [];
const exclude: string[] = [];
const strs = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
for (const r of rows) {
const h = (r.hard ?? {}) as { include?: unknown; exclude?: unknown };
include.push(strs(h.include));
exclude.push(...strs(h.exclude));
}
const inc = allOfAny(include);
return { ...(inc.length ? { include: inc } : {}), ...(exclude.length ? { exclude } : {}) };
}
/** 版本目标硬条件 + 规则组硬条件:两边的 include 都要满足,exclude 取并集 */
export function mergeHard(base: SlotHardConditions, extra: SlotHardConditions): SlotHardConditions {
if (!extra.include?.length && !extra.exclude?.length) return base;
const out: SlotHardConditions = { ...base };
if (extra.exclude?.length) out.exclude = [...(base.exclude ?? []), ...extra.exclude];
if (extra.include?.length) out.include = allOfAny([base.include ?? [], extra.include]);
return out;
}
/** 语义指纹:类型 + 范围 + 层 + 硬排除(规范化规则串)+ 是否必须命中 */
@@ -0,0 +1,171 @@
import type {
CalendarEntryState,
SubscriptionCalendar,
SubscriptionCalendarEntry,
SubscriptionMediaKind,
} from "@umt/contracts";
import type { SubscriptionRow } from "@umt/db";
import type { Exec } from "./common.ts";
import { buildSubscriptionModels, type SubscriptionModel } from "./coverage.ts";
import { TRACKING_SUB_STATUSES } from "./subscriptions.ts";
// 订阅日历(docs/design/subscriptions.md §4):事件 = 追踪中订阅范围内集的播出日期(集内容单元 air_date,
// 由 refreshMetadata 按 TMDB 季集数据写入),同日同季聚合;电影取上映日期(元数据 releaseDate)。
// 状态取主版本那一行的格(没有版本目标时取"库内任一版本"):全已入库 have / 部分 partial / 都没有 missing /
// 播出日期在今天之后 upcoming。范围内没有播出日期的集列进 unknown(界面显示"播出日期未知")。
// 与覆盖矩阵同一套模型(buildSubscriptionModels),口径一致。
const MAX_SUBS = 2000;
const MAX_UNKNOWN_EPISODES = 50;
export const CALENDAR_MAX_DAYS = 62;
const pad = (n: number) => String(n).padStart(2, "0");
/** S01E03 / S01E03–E05 / S01E03、E05(不连续时逐个列) */
export function calendarLabel(season: number | null, episodes: readonly number[]): string {
if (!episodes.length) return season === null ? "当期" : `S${pad(season)}`;
const s = season === null ? "" : `S${pad(season)}`;
const eps = [...episodes].sort((a, b) => a - b);
const contiguous = eps.every((e, i) => i === 0 || e === eps[i - 1]! + 1);
if (eps.length === 1) return `${s}E${pad(eps[0]!)}`;
if (contiguous) return `${s}E${pad(eps[0]!)}–E${pad(eps.at(-1)!)}`;
return `${s}${eps.map((e) => `E${pad(e)}`).join("、")}`;
}
/** 媒体实体元数据里的上映日期(releaseDate / firstAirDate,字符串或 {value}) */
export function releaseDateOf(metadata: unknown): string | null {
const m = (metadata ?? {}) as Record<string, unknown>;
for (const k of ["releaseDate", "release_date"]) {
const v = m[k];
const s =
typeof v === "string"
? v
: typeof (v as { value?: unknown } | null)?.value === "string"
? (v as { value: string }).value
: undefined;
if (s && /^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10);
}
return null;
}
function stateOf(cells: { state: string; reason: string | null }[]): CalendarEntryState {
const have = cells.filter((c) => c.state === "have").length;
if (have === cells.length) return "have";
if (have > 0) return "partial";
if (cells.every((c) => c.state === "skipped" && c.reason === "not_aired")) return "upcoming";
return "missing";
}
const titleOf = (m: SubscriptionModel) => m.entity?.title ?? m.sub.title_query ?? "未命名";
export function calendarFromModels(
models: readonly SubscriptionModel[],
range: { from: string; to: string },
movieDates: ReadonlyMap<string, string | null>,
today: string,
): SubscriptionCalendar {
const entries: SubscriptionCalendarEntry[] = [];
const unknown: SubscriptionCalendar["unknown"] = [];
const inRange = (d: string) => d >= range.from && d <= range.to;
for (const m of models) {
const row = m.rows.find((r) => r.primary) ?? m.rows.find((r) => r.slotId === null);
const base = {
subscriptionId: m.sub.id,
mediaEntityId: m.sub.media_entity_id,
title: titleOf(m),
mediaKind: m.sub.media_kind as SubscriptionMediaKind,
};
if (m.sub.media_kind === "movie") {
const date = m.entity ? (movieDates.get(m.entity.id) ?? null) : null;
if (!date) {
unknown.push({ ...base, seasonNumber: null, episodes: [] });
continue;
}
if (!inRange(date)) continue;
const have = row ? row.cells.some((c) => c.state === "have") : false;
entries.push({
...base,
date,
seasonNumber: null,
episodes: [],
label: "上映",
state: have ? "have" : date > today ? "upcoming" : "missing",
have: have ? 1 : 0,
total: 1,
});
continue;
}
if (!row) {
unknown.push({ ...base, seasonNumber: m.seasonNumber, episodes: [] });
continue;
}
// 展平列与格(顺序一致)
const cols = m.seasons.flatMap((s) => s.columns.map((c) => ({ ...c, seasonNumber: s.seasonNumber })));
const groups = new Map<string, { date: string; season: number | null; eps: number[]; cells: typeof row.cells }>();
const undated = new Map<number | null, number[]>();
cols.forEach((c, i) => {
const cell = row.cells[i];
if (!cell || (cell.state === "skipped" && cell.reason === "out_of_range")) return;
if (!c.airDate) {
if (cell.state !== "have" && c.episode !== null) {
const list = undated.get(c.seasonNumber) ?? [];
list.push(c.episode);
undated.set(c.seasonNumber, list);
}
return;
}
if (!inRange(c.airDate)) return;
const k = `${c.airDate}|${c.seasonNumber ?? ""}`;
const g = groups.get(k) ?? { date: c.airDate, season: c.seasonNumber, eps: [], cells: [] };
if (c.episode !== null) g.eps.push(c.episode);
g.cells.push(cell);
groups.set(k, g);
});
for (const g of groups.values()) {
const have = g.cells.filter((c) => c.state === "have").length;
entries.push({
...base,
date: g.date,
seasonNumber: g.season,
episodes: g.eps.sort((a, b) => a - b),
label: calendarLabel(g.season, g.eps),
state: stateOf(g.cells),
have,
total: g.cells.length,
});
}
// 没有任何季列(总集数未知且没见过集)也算未知
if (!cols.length) unknown.push({ ...base, seasonNumber: m.seasonNumber, episodes: [] });
for (const [season, eps] of undated)
unknown.push({ ...base, seasonNumber: season, episodes: eps.slice(0, MAX_UNKNOWN_EPISODES) });
}
entries.sort((a, b) => a.date.localeCompare(b.date) || a.title.localeCompare(b.title, "zh"));
return { from: range.from, to: range.to, entries, unknown };
}
/** 日历:追踪中(active / pending)订阅,可按类型筛 */
export async function subscriptionCalendar(
exec: Exec,
input: { from: string; to: string; mediaKind?: SubscriptionMediaKind | undefined },
now: Date,
): Promise<SubscriptionCalendar> {
let q = exec
.selectFrom("subscription")
.selectAll()
.where("status", "in", [...TRACKING_SUB_STATUSES]);
if (input.mediaKind) q = q.where("media_kind", "=", input.mediaKind);
const subs: SubscriptionRow[] = await q.orderBy("created_at", "desc").limit(MAX_SUBS).execute();
const models = await buildSubscriptionModels(exec, subs, now);
const movieIds = subs.filter((s) => s.media_kind === "movie" && s.media_entity_id).map((s) => s.media_entity_id!);
const movieDates = new Map<string, string | null>();
if (movieIds.length) {
const rows = await exec.selectFrom("media_entity").select(["id", "metadata"]).where("id", "in", movieIds).execute();
for (const r of rows) movieDates.set(r.id, releaseDateOf(r.metadata));
}
return calendarFromModels(
subs.map((s) => models.get(s.id)!).filter(Boolean),
{ from: input.from, to: input.to },
movieDates,
now.toISOString().slice(0, 10),
);
}
@@ -0,0 +1,83 @@
import type { SubscriptionResetResult } from "@umt/contracts";
import type { SubscriptionRow } from "@umt/db";
import { sql } from "@umt/db";
import type { AoDeps } from "./common.ts";
import { invalidateCoverage } from "./coverage.ts";
import { applySlotEvent } from "./downloads.ts";
import { INFLIGHT_PLAN_STATUSES } from "./subscriptions.ts";
// 订阅重置(差距 #24,对照 MP GET /subscribe/reset:清 note / episode_priority,lack = total,state = R)。
// 我们的"note"是在途计划:已预留未提交的、以及下载任务已不在下载器里的已提交计划都释放(计划 cancelled,
// Slot 走 release 迁移);下载器里仍在跑的计划保留(否则会重复下载)。清掉版本目标的延后记录,标记立即重评。
// 已入库资产、分集账本、总集数(含锁定)不动。
/** 仍算"活着"的下载任务状态:任务在下载器里,重置不能释放它的计划 */
const LIVE_DOWNLOAD = ["queued", "submitting", "downloading", "paused", "stalled", "completed", "importing"];
export async function resetSubscription(d: AoDeps, sub: SubscriptionRow): Promise<SubscriptionResetResult> {
const now = d.ports.now();
const out = await d.ctx.db.transaction().execute(async (trx) => {
const slots = sub.media_entity_id
? await trx
.selectFrom("acquisition_slot")
.select("id")
.where("media_entity_id", "=", sub.media_entity_id)
.where((eb) => eb.or([eb("subscription_id", "=", sub.id), eb("subscription_id", "is", null)]))
.forUpdate()
.execute()
: [];
const slotIds = slots.map((s) => s.id);
const released: string[] = [];
const kept: string[] = [];
let cleared = 0;
if (slotIds.length) {
const plans = await sql<{ id: string; slot_id: string; status: string; live: boolean }>`
select p.id, p.slot_id, p.status,
exists (select 1 from download_task t
where t.acquisition_plan_id = p.id and t.removed_at is null
and t.status in (${sql.join(LIVE_DOWNLOAD)})) as live
from acquisition_plan p
where p.slot_id in (${sql.join(slotIds)}) and p.status in (${sql.join([...INFLIGHT_PLAN_STATUSES])})
order by p.created_at
for update of p`.execute(trx);
for (const p of plans.rows) {
if (p.status === "submitted" && p.live) {
kept.push(p.id);
continue;
}
await trx
.updateTable("acquisition_plan")
.set({ status: "cancelled", status_reason: "subscription_reset" })
.where("id", "=", p.id)
.execute();
await applySlotEvent(d, trx, p.slot_id, { type: "release" }, { reason: "subscription_reset", planId: p.id });
released.push(p.id);
}
const del = await trx.deleteFrom("slot_deferral").where("slot_id", "in", slotIds).executeTakeFirst();
cleared = Number(del.numDeletedRows ?? 0);
await trx.updateTable("acquisition_slot").set({ next_evaluate_at: now }).where("id", "in", slotIds).execute();
}
return { released, kept, cleared };
});
invalidateCoverage(sub.id);
let reevaluated = false;
if (sub.media_entity_id && (sub.status === "active" || sub.status === "pending")) {
try {
await d.ctx.engine.enqueue(
"evaluateAcquisition",
{ subscriptionId: sub.id },
{ idempotencyKey: `reset:${sub.id}:${now.getTime()}`, traceId: sub.id },
);
reevaluated = true;
} catch (err) {
d.ctx.logger.debug({ err: { message: (err as Error).message } }, "重置后重评入队失败");
}
}
return {
subscriptionId: sub.id,
releasedPlans: out.released,
keptPlans: out.kept,
clearedDeferrals: out.cleared,
reevaluated,
};
}
@@ -1,8 +1,16 @@
import { type SubscriptionMediaKind, SubscriptionParams } from "@umt/contracts";
import { type SlotSpec, validateKeywordPattern } from "@umt/domain";
import {
type SubscriptionEffect,
type SubscriptionMediaKind,
SubscriptionParams,
type SubscriptionResolution,
type SubscriptionSourcePreset,
} from "@umt/contracts";
import { type SubscriptionRow, sql } from "@umt/db";
import { validateKeywordPattern } from "@umt/domain";
import { ApiException } from "../../../http/errors.ts";
import { jsonb } from "../util.ts";
import { hashJson, jsonb } from "../util.ts";
import type { Exec } from "./common.ts";
import { allOfAny } from "./rules.ts";
// W9-E 订阅编辑表单字段(docs/design/subscriptions.md §3):params 规范化与校验。
// 写入时兼容 MP 订阅字段与旧键,规范化后只存 SubscriptionParams 列出的键(其余未知键原样保留)。
@@ -13,6 +21,8 @@ import type { Exec } from "./common.ts";
// keyword / keywords(字符串)→ keywords[] filter_groups(组名或 id)→ ruleGroupIds
// best_version / best_version_full → 订阅版本目标的升级策略(按集 / 整季;电影 = 单片)
// autoFillVersions{enabled, budget{perDay, bytes}} / autoFill:boolean → autoFill + fillBudget{unitsPerDay, bytesPerDay}
// resolution / quality(resource_type) / effect(MP 标签或正则)→ resolution / source / effect 质量预设
// 保存后:include / exclude(+ 预设里只能用正则表达的 UHD / Atmos)同步为订阅私有规则组;质量预设写到该订阅自己的版本目标。
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -40,21 +50,6 @@ const strList = (v: unknown, key: string): string[] =>
return String(x).trim();
});
/** 订阅级 include/exclude 并入版本目标硬条件(作用于全部版本目标) */
export function withSubscriptionFilters(spec: SlotSpec, params: SubscriptionParams): SlotSpec {
const inc = params.include ?? [];
const exc = params.exclude ?? [];
if (!inc.length && !exc.length) return spec;
return {
...spec,
hard: {
...spec.hard,
...(inc.length ? { include: [...(spec.hard.include ?? []), ...inc] } : {}),
...(exc.length ? { exclude: [...(spec.hard.exclude ?? []), ...exc] } : {}),
},
};
}
async function idsByKey(
exec: Exec,
table: "site" | "downloader_instance" | "rule_group",
@@ -93,6 +88,236 @@ async function idsByKey(
});
}
// ── MP 质量预设(resolution / quality / effect)──
const RES_ENUM: readonly SubscriptionResolution[] = ["4k", "1080p", "720p"];
const SRC_ENUM: readonly SubscriptionSourcePreset[] = [
"bluray_disc",
"remux",
"bluray",
"uhd",
"web",
"hdtv",
"h265",
"h264",
];
const FX_ENUM: readonly SubscriptionEffect[] = ["dv", "hdr", "sdr", "atmos"];
const blank = (v: unknown) => v === undefined || v === null || v === "" || v === "全部" || v === "all";
/** MP 分辨率:枚举值、界面标签(4K / 1080P)或正则值("4K|2160p|x2160") */
export function parseMpResolution(v: string): SubscriptionResolution | null {
const t = v.trim().toLowerCase();
if ((RES_ENUM as readonly string[]).includes(t)) return t as SubscriptionResolution;
if (/4k|2160|uhd/.test(t)) return "4k";
if (/1080/.test(t)) return "1080p";
if (/720/.test(t)) return "720p";
return null;
}
/** MP 资源质量(quality / resource_type):蓝光原盘 / Remux / BluRay / UHD / WEB-DL / HDTV / H265 / H264 */
export function parseMpSource(v: string): SubscriptionSourcePreset | null {
// MP 的值是正则文本("Blu-?Ray"):去掉 ? 与反斜杠再按字面判断
const t = v.trim().toLowerCase().replace(/[?\\]/g, "");
if ((SRC_ENUM as readonly string[]).includes(t)) return t as SubscriptionSourcePreset;
if (/原盘|minibd|vc-?1|blu-?ray\.\+avc|bdmv/.test(t)) return "bluray_disc";
if (/remux/.test(t)) return "remux";
if (/blu-?ray|蓝光/.test(t)) return "bluray";
if (/uhd|ultra-?hd/.test(t)) return "uhd";
if (/web/.test(t)) return "web";
if (/hdtv/.test(t)) return "hdtv";
if (/265|hevc/.test(t)) return "h265";
if (/264|avc/.test(t)) return "h264";
return null;
}
/** MP 特效:杜比视界 / 杜比全景声 / HDR / SDR(标签或正则值) */
export function parseMpEffect(v: string): SubscriptionEffect | null {
const t = v.trim().toLowerCase();
if ((FX_ENUM as readonly string[]).includes(t)) return t as SubscriptionEffect;
if (/vision|dovi|杜比视界|\bdv\b/.test(t)) return "dv";
if (/atmos|全景声/.test(t)) return "atmos";
if (/sdr/.test(t) && !/hdr/.test(t)) return "sdr";
if (/hdr/.test(t)) return "hdr";
return null;
}
/** 质量预设 → 版本目标硬条件 / 软偏好;只能用正则表达的(UHD、Atmos)进私有规则组的包含 */
export interface PresetConditions {
hard: Record<string, unknown>;
soft: Record<string, unknown>;
include: string[];
}
export function presetConditions(p: Pick<SubscriptionParams, "resolution" | "source" | "effect">): PresetConditions {
const hard: Record<string, unknown> = {};
const soft: Record<string, unknown> = {};
const include: string[] = [];
switch (p.resolution) {
case "4k":
hard.resolutions = ["2160p"];
break;
case "1080p":
hard.resolutions = ["1080p", "1080i"];
break;
case "720p":
hard.resolutions = ["720p"];
break;
}
switch (p.source) {
case "bluray_disc":
hard.sources = ["BluRay-Disc"];
break;
case "remux":
hard.sources = ["REMUX"];
break;
case "bluray":
// MP "Blu-?Ray" 匹配整个蓝光族;软偏好按 原盘 > REMUX > 压制
hard.sources = ["BluRay-Disc", "REMUX", "BluRay"];
soft.sources = ["BluRay-Disc", "REMUX", "BluRay"];
break;
case "uhd":
include.push("UHD|Ultra-?HD");
break;
case "web":
hard.sources = ["WEB-DL", "WEBRip"];
soft.sources = ["WEB-DL", "WEBRip"];
break;
case "hdtv":
hard.sources = ["HDTV"];
break;
case "h265":
hard.codecs = ["H.265"];
break;
case "h264":
hard.codecs = ["H.264"];
break;
}
switch (p.effect) {
case "dv":
hard.hdr = { anyOf: ["DV"] };
soft.hdr = ["DV", "HDR10+", "HDR10"];
break;
case "hdr":
hard.hdr = { anyOf: ["HDR10", "HDR10+", "HLG", "HDR Vivid"] };
soft.hdr = ["HDR10+", "HDR10", "HDR Vivid", "HLG"];
break;
case "sdr":
hard.hdr = { requireSdr: true };
break;
case "atmos":
include.push("Atmos");
break;
}
return { hard, soft, include };
}
const sameJson = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
/**
* 质量预设写到该订阅自己的版本目标:新预设给出的字段覆盖;旧预设给过、新预设不再给的字段,
* 只有当版本目标上的值仍等于旧预设生成的值时才删掉(手改过的保留)。有变化 revision+1。
*/
export async function applySlotPreset(
exec: Exec,
input: { subscriptionId: string; prev: SubscriptionParams; next: SubscriptionParams },
): Promise<number> {
const a = presetConditions(input.prev);
const b = presetConditions(input.next);
if (sameJson(a, b)) return 0;
const slots = await exec
.selectFrom("acquisition_slot")
.select(["id", "hard_conditions", "soft_preferences", "revision"])
.where("subscription_id", "=", input.subscriptionId)
.execute();
let n = 0;
const merge = (cur: Record<string, unknown>, from: Record<string, unknown>, to: Record<string, unknown>) => {
const out = { ...cur };
for (const [k, v] of Object.entries(from)) if (!(k in to) && sameJson(out[k], v)) delete out[k];
return { ...out, ...to };
};
for (const s of slots) {
const curHard = (s.hard_conditions ?? {}) as Record<string, unknown>;
const curSoft = (s.soft_preferences ?? {}) as Record<string, unknown>;
const hard = merge(curHard, a.hard, b.hard);
const soft = merge(curSoft, a.soft, b.soft);
if (sameJson(hard, curHard) && sameJson(soft, curSoft)) continue;
await exec
.updateTable("acquisition_slot")
.set({ hard_conditions: jsonb(hard), soft_preferences: jsonb(soft), revision: s.revision + 1 })
.where("id", "=", s.id)
.execute();
n++;
}
return n;
}
// ── 订阅私有规则组(include / exclude)──
/** 私有组名:同名校验按未删除的组,订阅 id 前 13 位足够区分 */
export const privateGroupName = (subId: string) => `订阅私有 ${subId.slice(0, 13)}`;
/** 订阅当前的私有规则组(未删除) */
export async function privateRuleGroup(exec: Exec, subscriptionId: string) {
return exec
.selectFrom("rule_group")
.selectAll()
.where("deleted_at", "is", null)
.where(sql`scope ->> 'subscriptionId'`, "=", subscriptionId)
.orderBy("created_at")
.executeTakeFirst();
}
/**
* 订阅表单的包含 / 排除(+ 预设 UHD / Atmos)→ 订阅私有规则组 hard(scope.subscriptionId,不在规则页显示)。
* 条件为空或订阅已取消 → 软删;有变化 revision+1。返回组 id(没有为 null)。
*/
export async function syncPrivateRuleGroup(
exec: Exec,
sub: Pick<SubscriptionRow, "id" | "status" | "params">,
now: Date,
): Promise<string | null> {
const p = (sub.params ?? {}) as SubscriptionParams;
// 表单包含(至少命中一条)与预设 UHD / Atmos(每条都要命中)取 AND
const include = allOfAny([p.include ?? [], ...presetConditions(p).include.map((x) => [x])]);
const exclude = [...(p.exclude ?? [])];
const hard = {
...(include.length ? { include } : {}),
...(exclude.length ? { exclude } : {}),
};
const cur = await privateRuleGroup(exec, sub.id);
const empty = !include.length && !exclude.length;
if (empty || sub.status === "cancelled") {
if (cur)
await exec.updateTable("rule_group").set({ deleted_at: now, updated_at: now }).where("id", "=", cur.id).execute();
return null;
}
const fingerprint = `private:${sub.id}:${hashJson(hard).slice(0, 16)}`;
if (cur) {
if (sameJson(cur.hard, hard) && cur.enabled) return cur.id;
await exec
.updateTable("rule_group")
.set({ hard: jsonb(hard), fingerprint, enabled: true, revision: cur.revision + 1, updated_at: now })
.where("id", "=", cur.id)
.execute();
return cur.id;
}
const row = await exec
.insertInto("rule_group")
.values({
name: privateGroupName(sub.id),
description: "订阅表单的包含 / 排除,随订阅删除",
kind: "acquisition",
scope: jsonb({ subscriptionId: sub.id }),
ladder_required: false,
source: "manual",
fingerprint,
hard: jsonb(hard),
})
.returning("id")
.executeTakeFirstOrThrow();
return row.id;
}
/**
* 规范化 + 校验订阅 params(POST / PATCH 共用)。未知键原样保留;兼容字段改写后删除原键。
*/
@@ -167,6 +392,27 @@ export async function normalizeSubscriptionParams(
? "full_pack"
: "per_unit";
}
// MP 质量预设:resolution / quality(resource_type)/ effect;"全部"或空 = 不设
const presets: [string[], "resolution" | "source" | "effect", (v: string) => string | null, string][] = [
[["resolution"], "resolution", parseMpResolution, "分辨率"],
[["quality", "resource_type", "source"], "source", parseMpSource, "资源质量"],
[["effect"], "effect", parseMpEffect, "特效"],
];
for (const [keys, to, parse, label] of presets) {
let raw: unknown;
for (const k of keys) {
const v = k === to ? src[k] : take(k);
if (raw === undefined && v !== undefined) raw = v;
}
if (blank(raw)) {
delete src[to];
continue;
}
if (typeof raw !== "string") bad(`${label}只能是字符串`);
const v = parse(raw as string);
if (!v) bad(`${label}无法识别:${String(raw)}`);
src[to] = v;
}
const legacyFill = take("autoFillVersions") as
| { enabled?: unknown; budget?: { perDay?: unknown; bytes?: unknown } }
| undefined;
@@ -708,6 +708,38 @@ export async function sweepMetadataRefresh(d: AoDeps): Promise<number> {
return n;
}
/**
* 订阅指定的库根(params.libraryRootId):下载计划所属版本目标的订阅优先,否则取该作品最近的未取消订阅。
* 整理时只在作品没有路径绑定时使用(首次建绑定)。
*/
export async function subscriptionLibraryRoot(
exec: Exec,
input: { slotId: string | null; entityId: string },
): Promise<string | null> {
const pick = (p: unknown) => {
const v = (p as SubscriptionParams | null)?.libraryRootId;
return typeof v === "string" && v ? v : null;
};
if (input.slotId) {
const r = await exec
.selectFrom("acquisition_slot as s")
.innerJoin("subscription as sub", "sub.id", "s.subscription_id")
.select("sub.params")
.where("s.id", "=", input.slotId)
.executeTakeFirst();
if (r && pick(r.params)) return pick(r.params);
}
const subs = await exec
.selectFrom("subscription")
.select("params")
.where("media_entity_id", "=", input.entityId)
.where("status", "!=", "cancelled")
.orderBy("created_at", "desc")
.execute();
for (const s of subs) if (pick(s.params)) return pick(s.params);
return null;
}
// ── 订阅进度(API / UI) ──
export async function subscriptionProgress(exec: Exec, subId: string): Promise<SubscriptionProgress | null> {
@@ -1,11 +1,12 @@
import type { CandidateSeason } from "@umt/adapters";
import type { CandidateSeason, TmdbSeasonDetail } from "@umt/adapters";
import { sql } from "@umt/db";
import { resolveEpisodeTotal } from "@umt/domain";
import type { Exec } from "./common.ts";
// W5-C 季总集数账本(content_unit(kind=season).episode_total):refreshMetadata 用 TMDB seasons[].episodeCount 刷新。
// 规则在 domain resolveEpisodeTotal:只升;TMDB 回退不低于已覆盖最高集;人工锁定不改;未公布(0)保持未知。
// 季单元不存在时按元数据建(confirmed);不预建集单元(集单元仍由发布/文件懒建,provisional 不代表总数)。
// 季单元不存在时按元数据建(confirmed)。集单元:追踪中订阅范围内的季按 TMDB 季详情建或补播出日期(confirmed,
// 订阅日历与覆盖矩阵的未播出判定用);其余集单元仍由发布/文件懒建。
export interface SeasonTotalsResult {
seasons: { season: number; total: number | null; known: boolean; changed: boolean }[];
@@ -80,3 +81,89 @@ export async function applySeasonTotals(
out.activated = Number(act.numUpdatedRows ?? 0);
return out;
}
/**
* 追踪中订阅需要播出日期的季:订阅季(接续后续季时含之后各季);订阅没指定季时取最新两季。最多 6 季。
* 没有追踪中的订阅返回空(不额外请求 TMDB)。
*/
export async function trackedSeasonsForAirDates(
exec: Exec,
entityId: string,
known: readonly number[],
): Promise<number[]> {
const subs = await exec
.selectFrom("subscription as s")
.leftJoin("content_unit as u", "u.id", "s.season_unit_id")
.select(["s.season_scope", "u.season_number"])
.where("s.media_entity_id", "=", entityId)
.where("s.media_kind", "<>", "movie")
.where("s.status", "in", ["active", "pending", "paused"])
.execute();
if (!subs.length) return [];
const all = [...new Set(known.filter((n) => n >= 1))].sort((a, b) => a - b);
const want = new Set<number>();
for (const s of subs) {
if (s.season_number === null || s.season_number === undefined) {
for (const n of all.slice(-2)) want.add(n);
continue;
}
want.add(s.season_number);
if (s.season_scope === "following_seasons") for (const n of all) if (n > s.season_number) want.add(n);
}
return [...want]
.sort((a, b) => b - a)
.slice(0, 6)
.sort((a, b) => a - b);
}
/**
* TMDB 季详情 → 集单元播出日期:按(作品, 季, 集)找未退役的集单元,没有就建(confirmed,挂季单元);
* 播出日期只在 TMDB 给了值且不同时改写;标题只补空。返回改动的集数。
*/
export async function applyEpisodeAirDates(exec: Exec, entityId: string, detail: TmdbSeasonDetail): Promise<number> {
const season = detail.seasonNumber;
if (season < 1 || !detail.episodes.length) return 0;
const seasonUnit = await ensureSeason(exec, entityId, season, detail.name);
const units = await exec
.selectFrom("content_unit")
.select(["id", "episode_number", sql<string | null>`to_char(air_date, 'YYYY-MM-DD')`.as("air"), "title"])
.where("media_entity_id", "=", entityId)
.where("kind", "=", "episode")
.where("season_number", "=", season)
.where("status", "!=", "retired")
.orderBy("created_at")
.execute();
let n = 0;
for (const e of detail.episodes.slice(0, 500)) {
if (e.episodeNumber < 1) continue;
const air = e.airDate && /^\d{4}-\d{2}-\d{2}/.test(e.airDate) ? e.airDate.slice(0, 10) : null;
const hit = units.find((u) => u.episode_number === e.episodeNumber);
if (hit) {
const patch = {
...(air && air !== hit.air ? { air_date: air } : {}),
...(!hit.title && e.name ? { title: e.name } : {}),
};
if (Object.keys(patch).length) {
await exec.updateTable("content_unit").set(patch).where("id", "=", hit.id).execute();
n++;
}
continue;
}
await exec
.insertInto("content_unit")
.values({
media_entity_id: entityId,
parent_unit_id: seasonUnit,
kind: "episode",
season_number: season,
episode_number: e.episodeNumber,
status: "confirmed",
display_order: e.episodeNumber,
title: e.name,
air_date: air,
})
.execute();
n++;
}
return n;
}
@@ -1,4 +1,4 @@
import { type MetadataCandidate, TmdbError } from "@umt/adapters";
import { type MetadataCandidate, TmdbError, type TmdbSeasonDetail } from "@umt/adapters";
import type { EntityMetadataField, FieldOverrideInput, FinalFieldValue, MetadataSource } from "@umt/contracts";
import { sql } from "@umt/db";
import { ApiException } from "../../../http/errors.ts";
@@ -6,7 +6,12 @@ import { jsonb } from "../rows.ts";
import type { SourcesRuntime } from "../runtime.ts";
import { type Actor, audit, type Exec } from "./common.ts";
import { resolveEntityId, seedAliases } from "./entities.ts";
import { applySeasonTotals, type SeasonTotalsResult } from "./metadata-totals.ts";
import {
applyEpisodeAirDates,
applySeasonTotals,
type SeasonTotalsResult,
trackedSeasonsForAirDates,
} from "./metadata-totals.ts";
// refreshMetadata:字段级刷新(REQ §7.3)。来源值写 metadata_field;最终值 = 覆盖(锁)> 已锁字段保持 > 来源值。
// 只更新 media_entity 的展示字段与别名,不触碰 path_binding(路径绑定不随元数据变化)。
@@ -96,6 +101,22 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string)
}
throw e;
}
// 追踪中订阅范围内的季:拉季详情写集播出日期(订阅日历);失败不影响字段刷新
const airSeasons: TmdbSeasonDetail[] = [];
if (ident.entity_type === "tv" && detail.seasons?.length && typeof tmdb.season === "function") {
const want = await trackedSeasonsForAirDates(
ctx.db,
id,
detail.seasons.map((x) => x.seasonNumber),
);
for (const n of want) {
try {
airSeasons.push(await tmdb.season(ident.external_id, n));
} catch (e) {
ctx.logger.debug({ entityId: id, season: n, err: { message: (e as Error).message } }, "季详情获取失败");
}
}
}
const now = rt.now();
return ctx.db.transaction().execute(async (trx) => {
await writeTmdbFields(trx, id, detail, `tmdb:${ident.entity_type}/${ident.external_id}`, now);
@@ -113,6 +134,7 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string)
ident.entity_type === "tv" && detail.seasons?.length
? await applySeasonTotals(trx, id, detail.seasons, now)
: null;
for (const sd of airSeasons) await applyEpisodeAirDates(trx, id, sd);
await trx.updateTable("media_entity").set({ last_metadata_refresh_at: now }).where("id", "=", id).execute();
return { status: "refreshed" as const, ...r, ...(totals ? { seasons: totals.seasons } : {}) };
});
@@ -158,7 +158,15 @@ export async function searchSubscription(
keywords.push(v);
if (keywords.length >= SEARCH_MAX_KEYWORDS) break;
}
const params = (sub.params ?? {}) as { siteIds?: string[]; searchIntervalHours?: number };
const params = (sub.params ?? {}) as { siteIds?: string[]; searchIntervalHours?: number; keywords?: string[] };
// 订阅的搜索关键词(MP keyword)追加在标题/别名之后,不占 SEARCH_MAX_KEYWORDS;结果同样经识别 → 评估
// (订阅私有规则组的包含 / 排除在评估时过滤,与 RSS 来的资源同一口径)
for (const k of params.keywords ?? []) {
const v = typeof k === "string" ? k.trim() : "";
if (!v || seen.has(v.toLowerCase())) continue;
seen.add(v.toLowerCase());
keywords.push(v);
}
let sq = db.selectFrom("site").selectAll().where("enabled", "=", true).where("health_status", "!=", "failing");
if (params.siteIds?.length) sq = sq.where("id", "in", params.siteIds);
const sites = await sq.orderBy("name").execute();