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, app,
Api.rules.list, Api.rules.list,
async (_c, { query }) => { 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.includeDeleted !== "true") q = q.where("deleted_at", "is", null);
if (query.kind) q = q.where("kind", "=", query.kind); if (query.kind) q = q.where("kind", "=", query.kind);
if (query.source) q = q.where("source", "=", query.source); if (query.source) q = q.where("source", "=", query.source);
@@ -1,11 +1,13 @@
import { import {
type AcquisitionSlot, type AcquisitionSlot,
Api, Api,
type SlotTemplate,
type Subscription, type Subscription,
type SubscriptionGroup, type SubscriptionGroup,
type SubscriptionHistory, type SubscriptionHistory,
type SubscriptionOverviewItem, type SubscriptionOverviewItem,
type SubscriptionOverviewPage, type SubscriptionOverviewPage,
type SubscriptionParams,
type SubscriptionSort, type SubscriptionSort,
} from "@umt/contracts"; } from "@umt/contracts";
import type { SubscriptionRow } from "@umt/db"; import type { SubscriptionRow } from "@umt/db";
@@ -27,6 +29,8 @@ import {
subscriptionCoverage, subscriptionCoverage,
} from "../services/coverage.ts"; } from "../services/coverage.ts";
import { loadRuleCatalog } from "../services/rules.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 { import {
applyEpisodeTotal, applyEpisodeTotal,
enqueueSubscriptionSearch, enqueueSubscriptionSearch,
@@ -35,9 +39,11 @@ import {
TRACKING_SUB_STATUSES, TRACKING_SUB_STATUSES,
} from "../services/subscriptions.ts"; } from "../services/subscriptions.ts";
import { import {
applySlotPreset,
applySlotUpgrade, applySlotUpgrade,
type NormalizedParams, type NormalizedParams,
normalizeSubscriptionParams, normalizeSubscriptionParams,
syncPrivateRuleGroup,
} from "../services/subscriptions-params.ts"; } from "../services/subscriptions-params.ts";
import { actorOf, conflict, errMessage, jsonb, notFound, toApi, toApiList } from "../util.ts"; import { actorOf, conflict, errMessage, jsonb, notFound, toApi, toApiList } from "../util.ts";
import { mount, paginate } from "./helpers.ts"; import { mount, paginate } from "./helpers.ts";
@@ -100,8 +106,22 @@ async function assertNoDuplicate(
if (hit) conflict(`该作品同一季已有订阅:${hit.id}`); 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 (!sub.media_entity_id) return;
if (n.episodeTotal) { if (n.episodeTotal) {
const season = sub.season_unit_id const season = sub.season_unit_id
@@ -296,6 +316,20 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
}, },
{ tag: TAG }, { 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)), { mount(app, Api.subscriptions.get, async (_c, { params }) => toApi<Subscription>(await load(params.id)), {
tag: TAG, tag: TAG,
}); });
@@ -352,14 +386,20 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
const pol = const pol =
(body.policyRevisionId ? pols.find((p) => p.row.id === body.policyRevisionId) : undefined) ?? (body.policyRevisionId ? pols.find((p) => p.row.id === body.policyRevisionId) : undefined) ??
pols.find((p) => p.rules.defaultSlots?.length); 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, { await createSlotsFromTemplates(trx, {
entityId: r.media_entity_id, entityId: r.media_entity_id,
subscriptionId: r.id, subscriptionId: r.id,
policyRevisionId: pol.row.id, policyRevisionId: pol?.rules.defaultSlots?.length ? pol.row.id : null,
templates: pol.rules.defaultSlots, templates,
}); });
}
} }
} }
await applyDerived(trx, r, norm, now); await applyDerived(trx, r, norm, now);
@@ -417,7 +457,8 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
.where("id", "=", params.id) .where("id", "=", params.id)
.returningAll() .returningAll()
.executeTakeFirstOrThrow(); .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; return r;
}); });
invalidateCoverage(r.id); invalidateCoverage(r.id);
@@ -448,6 +489,8 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
.where("id", "=", sub.id) .where("id", "=", sub.id)
.returningAll() .returningAll()
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
// 私有规则组随订阅删除(取消),重新订阅时按 params 重建
if (to === "cancelled" || sub.status === "cancelled") await syncPrivateRuleGroup(db(), r, now);
invalidateCoverage(sub.id); invalidateCoverage(sub.id);
return r; return r;
}; };
@@ -538,6 +581,24 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
{ tag: TAG, status: 200 }, { 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(app, Api.subscriptions.search, async (_c, { params }) => search(await load(params.id)), { tag: TAG });
mount( mount(
@@ -31,9 +31,19 @@ import {
} from "@umt/domain"; } from "@umt/domain";
import { fromParsedColumns, slotSpecFromRow, toContractSlotEvaluation } from "@umt/mapping"; import { fromParsedColumns, slotSpecFromRow, toContractSlotEvaluation } from "@umt/mapping";
import { hashJson, jsonb, startOfDay, toApi } from "../util.ts"; 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 { 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 { import {
clearDeferral, clearDeferral,
rangeOf, rangeOf,
@@ -45,7 +55,6 @@ import {
subParams, subParams,
TRACKING_SUB_STATUSES, TRACKING_SUB_STATUSES,
} from "./subscriptions.ts"; } from "./subscriptions.ts";
import { withSubscriptionFilters } from "./subscriptions-params.ts";
import { decideVolume, loadVolumes } from "./volumes.ts"; import { decideVolume, loadVolumes } from "./volumes.ts";
// evaluateAcquisition:release.resolved → 匹配订阅/Slot → 模式 → 确定性评估 → 预算/缓存卷 → 冻结计划并预留。 // 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"] }); await note(c, { ...base, decision: "skipped", mode: null, rejected: [], reasons: ["site_out_of_scope"] });
return done; return done;
} }
const spec = withSubscriptionFilters(slotSpecFromRow(toApi<AcquisitionSlot>(slot)), params); const spec = slotSpecFromRow(toApi<AcquisitionSlot>(slot));
const ctx = contextOf(rel, now); const ctx = contextOf(rel, now);
// 规则组:版本目标 ruleGroupIds → 订阅 ruleGroupIds → 订阅默认链;开升级叠加洗版默认链 // 规则组:版本目标 ruleGroupIds → 订阅 ruleGroupIds → 订阅默认链;开升级叠加洗版默认链
c.rules ??= loadRuleCatalog(db); c.rules ??= loadRuleCatalog(db);
@@ -559,6 +568,8 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
siteId: rel.site_id, siteId: rel.site_id,
subscriptionId: sub?.id, subscriptionId: sub?.id,
}); });
// 规则组的结构化硬条件(订阅私有组的包含 / 排除)并入版本目标硬条件
spec.hard = mergeHard(spec.hard, chain.hard);
if (chain.groups.length) { if (chain.groups.length) {
spec.rules = { groups: chain.groups, atoms: chain.atoms }; spec.rules = { groups: chain.groups, atoms: chain.atoms };
c.langs ??= new Map(); c.langs ??= new Map();
@@ -882,10 +893,28 @@ async function gates(
const { d } = c; const { d } = c;
const blocked = await automationBlock(d); const blocked = await automationBlock(d);
if (blocked) return { ok: false, reasons: [blocked], rejected: [] }; 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 insts = (await loadInstances(d.ctx.db)).filter((t) => t.savePath && (!wanted || t.inst.id === wanted));
const inst = insts[0]; const inst = insts[0];
if (!inst?.savePath) return { ok: false, reasons: ["no_downloader_instance"], rejected: [] }; 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 budget = await d.ctx.settings.get("budget");
const b = checkBudget( 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( 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}` }; if (plan.status !== "reserved") return { status: "skipped", reason: `plan_${plan.status}` };
const blocked = await automationBlock(d); const blocked = await automationBlock(d);
if (blocked) return { status: "deferred", reason: blocked }; 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); 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"); 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 site = await db.selectFrom("site").selectAll().where("id", "=", rel.site_id).executeTakeFirst();
const client = await d.ports.downloader(topo.inst); const client = await d.ports.downloader(topo.inst);
const tag = topo.inst.managed_tag; const tag = topo.inst.managed_tag;
const savePath = topo.savePath; // 评估时定下的保存路径(订阅 downloadSavePath 经路径映射后的下载器侧路径),没有用实例默认
const savePath = ev.savePath || topo.savePath;
const view = (t: QbTorrent) => ({ const view = (t: QbTorrent) => ({
hash: t.hash, hash: t.hash,
@@ -45,7 +45,7 @@ import { effectiveNaming, ensureNamingVersion, loadNamingEntity, namingByVersion
import { PathGuardError } from "./organize-guard.ts"; import { PathGuardError } from "./organize-guard.ts";
import { type FrozenTransfer, finalizeTransfer, planTransfer, type TransferSpec } from "./organize-transfer.ts"; import { type FrozenTransfer, finalizeTransfer, planTransfer, type TransferSpec } from "./organize-transfer.ts";
import { hnrSatisfied } from "./retention.ts"; import { hnrSatisfied } from "./retention.ts";
import { slotComplete as sharedSlotComplete } from "./subscriptions.ts"; import { slotComplete as sharedSlotComplete, subscriptionLibraryRoot } from "./subscriptions.ts";
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→ // 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。 // 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
@@ -247,11 +247,15 @@ async function chooseRoot(
entity: MediaEntityRow, entity: MediaEntityRow,
bindings: DomainBinding[], bindings: DomainBinding[],
wanted?: string, wanted?: string,
/** 订阅指定的库根:只在作品还没有路径绑定时用(首次整理建绑定),不存在/停用时按类型默认 */
preferred?: string | null,
): Promise<LibraryRootRow | null> { ): Promise<LibraryRootRow | null> {
const roots = await exec.selectFrom("library_root").selectAll().where("enabled", "=", true).execute(); const roots = await exec.selectFrom("library_root").selectAll().where("enabled", "=", true).execute();
if (wanted) return roots.find((r) => r.id === wanted) ?? null; if (wanted) return roots.find((r) => r.id === wanted) ?? null;
const bound = bindings.find((b) => b.active !== false); const bound = bindings.find((b) => b.active !== false);
if (bound) return roots.find((r) => r.id === bound.libraryRootId) ?? null; 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); const kinds = libraryKinds(entity.kind);
return ( return (
roots roots
@@ -426,7 +430,13 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
.where("status", "=", "active") .where("status", "=", "active")
.execute(); .execute();
const bindings = bindingRows.map((r) => toDomainPathBinding(toApi<PathBinding>(r))); 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); if (!root) return fail("no_library_root", {}, entity.id);
const targetDir = req.targetDir?.replace(/\/+$/, ""); const targetDir = req.targetDir?.replace(/\/+$/, "");
if (targetDir !== undefined) { if (targetDir !== undefined) {
@@ -25,6 +25,7 @@ import {
type RuleGroupSpec, type RuleGroupSpec,
type RuleLayer, type RuleLayer,
type RuleParseError, type RuleParseError,
type SlotHardConditions,
type SlotRuleChain, type SlotRuleChain,
serializeExpr, serializeExpr,
serializeLayers, serializeLayers,
@@ -144,7 +145,10 @@ export interface ResolveInput extends RuleTarget {
* 引用链:订阅私有组 → (版本目标 ruleGroupIds || 订阅 ruleGroupIds || 该类默认链)→ 开升级时叠加洗版默认链。 * 引用链:订阅私有组 → (版本目标 ruleGroupIds || 订阅 ruleGroupIds || 该类默认链)→ 开升级时叠加洗版默认链。
* 按 scope 过滤、跳过停用/已删;pri 取链上最后一个带层的组(对齐 MP 多组串联)。 * 按 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 explicit = input.slotRuleGroupIds?.length ? input.slotRuleGroupIds : (input.subRuleGroupIds ?? []);
const ids: string[] = []; const ids: string[] = [];
const push = (id: string) => { const push = (id: string) => {
@@ -158,7 +162,41 @@ export function resolveRuleChain(cat: RuleCatalog, input: ResolveInput): SlotRul
const rows = ids const rows = ids
.map((id) => cat.groups.get(id)) .map((id) => cat.groups.get(id))
.filter((g): g is RuleGroupRow => !!g && g.enabled && inScope(g, input)); .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 {
import { type SlotSpec, validateKeywordPattern } from "@umt/domain"; 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 { ApiException } from "../../../http/errors.ts";
import { jsonb } from "../util.ts"; import { hashJson, jsonb } from "../util.ts";
import type { Exec } from "./common.ts"; import type { Exec } from "./common.ts";
import { allOfAny } from "./rules.ts";
// W9-E 订阅编辑表单字段(docs/design/subscriptions.md §3):params 规范化与校验。 // W9-E 订阅编辑表单字段(docs/design/subscriptions.md §3):params 规范化与校验。
// 写入时兼容 MP 订阅字段与旧键,规范化后只存 SubscriptionParams 列出的键(其余未知键原样保留)。 // 写入时兼容 MP 订阅字段与旧键,规范化后只存 SubscriptionParams 列出的键(其余未知键原样保留)。
@@ -13,6 +21,8 @@ import type { Exec } from "./common.ts";
// keyword / keywords(字符串)→ keywords[] filter_groups(组名或 id)→ ruleGroupIds // keyword / keywords(字符串)→ keywords[] filter_groups(组名或 id)→ ruleGroupIds
// best_version / best_version_full → 订阅版本目标的升级策略(按集 / 整季;电影 = 单片) // best_version / best_version_full → 订阅版本目标的升级策略(按集 / 整季;电影 = 单片)
// autoFillVersions{enabled, budget{perDay, bytes}} / autoFill:boolean → autoFill + fillBudget{unitsPerDay, bytesPerDay} // 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; 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(); 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( async function idsByKey(
exec: Exec, exec: Exec,
table: "site" | "downloader_instance" | "rule_group", 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 共用)。未知键原样保留;兼容字段改写后删除原键。 * 规范化 + 校验订阅 params(POST / PATCH 共用)。未知键原样保留;兼容字段改写后删除原键。
*/ */
@@ -167,6 +392,27 @@ export async function normalizeSubscriptionParams(
? "full_pack" ? "full_pack"
: "per_unit"; : "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 const legacyFill = take("autoFillVersions") as
| { enabled?: unknown; budget?: { perDay?: unknown; bytes?: unknown } } | { enabled?: unknown; budget?: { perDay?: unknown; bytes?: unknown } }
| undefined; | undefined;
@@ -708,6 +708,38 @@ export async function sweepMetadataRefresh(d: AoDeps): Promise<number> {
return n; 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) ── // ── 订阅进度(API / UI) ──
export async function subscriptionProgress(exec: Exec, subId: string): Promise<SubscriptionProgress | null> { 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 { sql } from "@umt/db";
import { resolveEpisodeTotal } from "@umt/domain"; import { resolveEpisodeTotal } from "@umt/domain";
import type { Exec } from "./common.ts"; import type { Exec } from "./common.ts";
// W5-C 季总集数账本(content_unit(kind=season).episode_total):refreshMetadata 用 TMDB seasons[].episodeCount 刷新。 // W5-C 季总集数账本(content_unit(kind=season).episode_total):refreshMetadata 用 TMDB seasons[].episodeCount 刷新。
// 规则在 domain resolveEpisodeTotal:只升;TMDB 回退不低于已覆盖最高集;人工锁定不改;未公布(0)保持未知。 // 规则在 domain resolveEpisodeTotal:只升;TMDB 回退不低于已覆盖最高集;人工锁定不改;未公布(0)保持未知。
// 季单元不存在时按元数据建(confirmed);不预建集单元(集单元仍由发布/文件懒建,provisional 不代表总数)。 // 季单元不存在时按元数据建(confirmed)。集单元:追踪中订阅范围内的季按 TMDB 季详情建或补播出日期(confirmed,
// 订阅日历与覆盖矩阵的未播出判定用);其余集单元仍由发布/文件懒建。
export interface SeasonTotalsResult { export interface SeasonTotalsResult {
seasons: { season: number; total: number | null; known: boolean; changed: boolean }[]; seasons: { season: number; total: number | null; known: boolean; changed: boolean }[];
@@ -80,3 +81,89 @@ export async function applySeasonTotals(
out.activated = Number(act.numUpdatedRows ?? 0); out.activated = Number(act.numUpdatedRows ?? 0);
return out; 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 type { EntityMetadataField, FieldOverrideInput, FinalFieldValue, MetadataSource } from "@umt/contracts";
import { sql } from "@umt/db"; import { sql } from "@umt/db";
import { ApiException } from "../../../http/errors.ts"; import { ApiException } from "../../../http/errors.ts";
@@ -6,7 +6,12 @@ import { jsonb } from "../rows.ts";
import type { SourcesRuntime } from "../runtime.ts"; import type { SourcesRuntime } from "../runtime.ts";
import { type Actor, audit, type Exec } from "./common.ts"; import { type Actor, audit, type Exec } from "./common.ts";
import { resolveEntityId, seedAliases } from "./entities.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;最终值 = 覆盖(锁)> 已锁字段保持 > 来源值。 // refreshMetadata:字段级刷新(REQ §7.3)。来源值写 metadata_field;最终值 = 覆盖(锁)> 已锁字段保持 > 来源值。
// 只更新 media_entity 的展示字段与别名,不触碰 path_binding(路径绑定不随元数据变化)。 // 只更新 media_entity 的展示字段与别名,不触碰 path_binding(路径绑定不随元数据变化)。
@@ -96,6 +101,22 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string)
} }
throw e; 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(); const now = rt.now();
return ctx.db.transaction().execute(async (trx) => { return ctx.db.transaction().execute(async (trx) => {
await writeTmdbFields(trx, id, detail, `tmdb:${ident.entity_type}/${ident.external_id}`, now); 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 ident.entity_type === "tv" && detail.seasons?.length
? await applySeasonTotals(trx, id, detail.seasons, now) ? await applySeasonTotals(trx, id, detail.seasons, now)
: null; : 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(); 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 } : {}) }; return { status: "refreshed" as const, ...r, ...(totals ? { seasons: totals.seasons } : {}) };
}); });
@@ -158,7 +158,15 @@ export async function searchSubscription(
keywords.push(v); keywords.push(v);
if (keywords.length >= SEARCH_MAX_KEYWORDS) break; 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"); let sq = db.selectFrom("site").selectAll().where("enabled", "=", true).where("health_status", "!=", "failing");
if (params.siteIds?.length) sq = sq.where("id", "in", params.siteIds); if (params.siteIds?.length) sq = sq.where("id", "in", params.siteIds);
const sites = await sq.orderBy("name").execute(); const sites = await sq.orderBy("name").execute();
@@ -292,6 +292,8 @@ describe.skipIf(!hasPg)("E7 常规剧集纵切", () => {
.select(["episode_number", "status"]) .select(["episode_number", "status"])
.where("media_entity_id", "=", entityId) .where("media_entity_id", "=", entityId)
.where("kind", "=", "episode") .where("kind", "=", "episode")
// 追踪订阅的最新季(S2/S3)由元数据刷新按 TMDB 季详情建集单元(日历用);这里只看包所在的 S1
.where("season_number", "=", 1)
.orderBy("episode_number") .orderBy("episode_number")
.execute(); .execute();
expect(eps).toEqual([ expect(eps).toEqual([
@@ -0,0 +1,154 @@
import { createMockNexusPhpSite, createMockTmdb, type MockNexusPhpSite, type MockTmdb } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
import { trackedSeasonsForAirDates } from "../../src/modules/sources-identity/service/metadata-totals.ts";
import { searchSubscription } from "../../src/modules/sources-identity/tasks.ts";
import { createHarness, type Harness, json, pgAvailable } from "../sources-identity/harness.ts";
// C1-A:订阅搜索关键词追加到主动搜索;元数据刷新按 TMDB 季详情给追踪中订阅的季写集播出日期(订阅日历数据源)。
const up = await pgAvailable();
describe.skipIf(!up)("订阅搜索关键词与集播出日期", () => {
let h: Harness;
let site: MockNexusPhpSite;
let tmdb: MockTmdb;
let siteId: string;
beforeAll(async () => {
site = await createMockNexusPhpSite();
tmdb = await createMockTmdb();
h = await createHarness("subsc1a", { tmdbUrl: tmdb.url });
const ok = await json(
await h.req("/sites", {
body: {
key: "audiences",
name: "观众",
adapter: "nexusphp",
baseUrl: `${site.baseUrl}/`,
rateLimit: { minIntervalMs: 0, concurrency: 4 },
},
}),
);
expect(ok.status).toBe(200);
siteId = ok.body.data.id;
await h.req("/site-accounts", { body: { siteId, label: "主号", authKind: "cookie", credential: site.cookie } });
});
afterAll(async () => {
await h?.close();
await site?.close();
await tmdb?.close();
});
it("params.keywords 追加在标题之后(去重,不占标题检索词上限),每个站点每个词搜一次", async () => {
const e = (
await h.db
.insertInto("media_entity")
.values({ kind: "movie", title: "Dune", original_title: "Dune", year: 2021, status: "active" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
const sub = (
await h.db
.insertInto("subscription")
.values({
media_entity_id: e,
media_kind: "movie",
mode: "once",
params: JSON.stringify({ keywords: ["Dune Part One", "dune", " "] }),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
const r = await searchSubscription(h.rt, sub);
expect(r).toMatchObject({ status: "ok", keywords: ["Dune", "Dune Part One"], sites: [siteId], searches: 2 });
const snaps = await h.db
.selectFrom("site_snapshot")
.select("query")
.where("kind", "=", "live_search")
.where("query", "=", "Dune Part One")
.execute();
expect(snaps).toHaveLength(1);
});
it("元数据刷新:追踪中订阅的季按 TMDB 季详情建集单元并写播出日期;已有集单元只补日期;没有追踪订阅不拉季详情", async () => {
const e = (
await h.db
.insertInto("media_entity")
.values({ kind: "series", title: "The Bear", original_title: "The Bear", year: 2022, status: "active" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
await h.db
.insertInto("external_identity")
.values({
media_entity_id: e,
provider: "tmdb",
entity_type: "tv",
external_id: "136315",
status: "accepted",
})
.execute();
expect(await trackedSeasonsForAirDates(h.db, e, [1, 2, 3])).toEqual([]);
const seasonRequests = () => tmdb.callsTo(/\/season\/\d+/).length;
const before = seasonRequests();
await refreshMetadata(h.rt, e);
expect(seasonRequests()).toBe(before);
const s2 = (
await h.db
.insertInto("content_unit")
.values({ media_entity_id: e, kind: "season", season_number: 2, status: "confirmed" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
// 已有的 E01(发布懒建,没有日期)
const e1 = (
await h.db
.insertInto("content_unit")
.values({ media_entity_id: e, kind: "episode", season_number: 2, episode_number: 1, status: "provisional" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
await h.db
.insertInto("subscription")
.values({
media_entity_id: e,
media_kind: "series",
mode: "continuous",
season_unit_id: s2,
season_scope: "following_seasons",
})
.execute();
expect(await trackedSeasonsForAirDates(h.db, e, [1, 2, 3])).toEqual([2, 3]);
const r = await refreshMetadata(h.rt, e);
expect(r.status).toBe("refreshed");
const eps = await h.db
.selectFrom("content_unit")
.select(["id", "season_number", "episode_number", "status", "title"])
.select((eb) => eb.fn<string>("to_char", [eb.ref("air_date"), eb.val("YYYY-MM-DD")]).as("air"))
.where("media_entity_id", "=", e)
.where("kind", "=", "episode")
.orderBy("season_number")
.orderBy("episode_number")
.execute();
expect(eps.filter((x) => x.season_number === 2)).toHaveLength(10);
expect(eps.filter((x) => x.season_number === 3)).toHaveLength(10);
expect(eps.filter((x) => x.season_number === 1)).toHaveLength(0);
const first = eps.find((x) => x.season_number === 2 && x.episode_number === 1)!;
expect(first).toMatchObject({ id: e1, status: "provisional", air: "2023-06-22" });
expect(eps.find((x) => x.season_number === 2 && x.episode_number === 2)).toMatchObject({
status: "confirmed",
air: "2023-06-29",
});
// 再刷新幂等:不重复建
await refreshMetadata(h.rt, e);
const n = await h.db
.selectFrom("content_unit")
.select((eb) => eb.fn.countAll<number>().as("n"))
.where("media_entity_id", "=", e)
.where("kind", "=", "episode")
.executeTakeFirstOrThrow();
expect(Number(n.n)).toBe(20);
});
});
@@ -0,0 +1,513 @@
import { type DB, sql } from "@umt/db";
import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit";
import type { Kysely } from "kysely";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
createHarness,
fakeDisk,
type Harness,
pgAvailable,
seedBase,
seedEntity,
seedRelease,
} from "../../src/modules/acquisition-organize/__tests__/harness.ts";
import {
evaluateAcquisition,
subscriptionSavePath,
} from "../../src/modules/acquisition-organize/services/acquisition.ts";
import { submitDownload } from "../../src/modules/acquisition-organize/services/downloads.ts";
import { planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import { allOfAny, chainHard, mergeHard } from "../../src/modules/acquisition-organize/services/rules.ts";
import { calendarLabel, releaseDateOf } from "../../src/modules/acquisition-organize/services/subscription-calendar.ts";
import {
parseMpEffect,
parseMpResolution,
parseMpSource,
presetConditions,
} from "../../src/modules/acquisition-organize/services/subscriptions-params.ts";
// C1-A 订阅遗留收尾:包含 / 排除 → 订阅私有规则组(评估链纳入、随订阅删除)、MP 分辨率 / 来源 / 特效 → 版本目标、
// 保存路径经路径映射传给 qB、库根用于首次整理、下载器实例、重置进度、日历。
const hasPg = await pgAvailable();
const SIZE = 300_000;
describe("C1-A 纯函数", () => {
it("MP 质量字段:标签 / 正则值 / 枚举都能识别", () => {
expect(parseMpResolution("4K|2160p|x2160")).toBe("4k");
expect(parseMpResolution("1080[pi]|x1080")).toBe("1080p");
expect(parseMpResolution("720P")).toBe("720p");
expect(parseMpResolution("480p")).toBeNull();
expect(parseMpSource("Blu-?Ray.+VC-?1|Blu-?Ray.+AVC|UHD.+blu-?ray.+HEVC|MiniBD")).toBe("bluray_disc");
expect(parseMpSource("Remux")).toBe("remux");
expect(parseMpSource("Blu-?Ray")).toBe("bluray");
expect(parseMpSource("UHD|UltraHD")).toBe("uhd");
expect(parseMpSource("WEB-?DL|WEB-?RIP")).toBe("web");
expect(parseMpSource("[Hx].?265|HEVC")).toBe("h265");
expect(parseMpEffect("Dolby[\\s.]+Vision|DOVI|[\\s.]+DV[\\s.]+")).toBe("dv");
expect(parseMpEffect("Dolby[\\s.]*\\+?Atmos|Atmos")).toBe("atmos");
expect(parseMpEffect("[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+")).toBe("hdr");
expect(parseMpEffect("SDR")).toBe("sdr");
});
it("质量预设 → 硬条件 / 软偏好;UHD / Atmos 只能走包含", () => {
expect(presetConditions({ resolution: "4k", source: "web", effect: "dv" })).toEqual({
hard: { resolutions: ["2160p"], sources: ["WEB-DL", "WEBRip"], hdr: { anyOf: ["DV"] } },
soft: { sources: ["WEB-DL", "WEBRip"], hdr: ["DV", "HDR10+", "HDR10"] },
include: [],
});
expect(presetConditions({ source: "uhd", effect: "atmos" }).include).toEqual(["UHD|Ultra-?HD", "Atmos"]);
expect(presetConditions({ effect: "sdr" }).hard).toEqual({ hdr: { requireSdr: true } });
});
it("包含取 AND:多组合成前瞻正则;排除取并集", () => {
expect(allOfAny([["a", "b"]])).toEqual(["a", "b"]);
const [re] = allOfAny([["中字", "CHS"], ["UHD"]]);
const rx = new RegExp(re!, "i");
expect(rx.test("Show 2160p UHD 中字")).toBe(true);
expect(rx.test("Show 2160p 中字")).toBe(false);
expect(chainHard([{ hard: { include: ["x"] } }, { hard: { exclude: ["y"] } }, { hard: {} }])).toEqual({
include: ["x"],
exclude: ["y"],
});
expect(mergeHard({ exclude: ["a"] }, { exclude: ["b"] })).toEqual({ exclude: ["a", "b"] });
expect(mergeHard({ include: ["a"] }, {})).toEqual({ include: ["a"] });
});
it("保存路径:本机路径经映射换成下载器路径;下载器侧路径原样;都不匹配为 null;无映射同路径", () => {
const t = {
inst: { id: "i1" } as never,
mappings: [{ instanceId: "i1", downloaderPath: "/downloads", hostPath: "/mnt/cache" }],
};
expect(subscriptionSavePath(t, "/mnt/cache/tv/")).toBe("/downloads/tv");
expect(subscriptionSavePath(t, "/downloads/tv")).toBe("/downloads/tv");
expect(subscriptionSavePath(t, "/elsewhere")).toBeNull();
expect(subscriptionSavePath({ inst: t.inst, mappings: [] }, "/x/y")).toBe("/x/y");
});
it("日历标签与上映日期", () => {
expect(calendarLabel(1, [3])).toBe("S01E03");
expect(calendarLabel(1, [4, 3])).toBe("S01E03–E04");
expect(calendarLabel(2, [1, 3])).toBe("S02E01、E03");
expect(calendarLabel(null, [])).toBe("当期");
expect(releaseDateOf({ releaseDate: { value: "2026-10-02", source: "tmdb" } })).toBe("2026-10-02");
expect(releaseDateOf({})).toBeNull();
});
});
describe.skipIf(!hasPg)("C1-A 订阅遗留收尾(真实 PG + mock qB)", () => {
let h: Harness;
let qb: MockQb;
let lib: TempTree;
let lib2: TempTree;
let cache: TempTree;
const disk = fakeDisk(10_000_000_000);
const torrents = new Map<string, Uint8Array>();
let base: Awaited<ReturnType<typeof seedBase>>;
const db = (): Kysely<DB> => h.db;
beforeAll(async () => {
qb = await createMockQb({ addVisibleAfterPolls: 0 });
lib = await createTempTree({}, { sentinel: true, prefix: "umt-c1a-lib-" });
lib2 = await createTempTree({}, { sentinel: true, prefix: "umt-c1a-lib2-" });
cache = await createTempTree({ "Root.Pick.2021.1080p.BluRay.x264-GRP.mkv": SIZE }, { prefix: "umt-c1a-cache-" });
disk.state.watch = [cache.root];
disk.setPct(10);
h = await createHarness("subsc1a", {
diskUsage: disk.fn,
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }),
});
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
}, 60_000);
afterAll(async () => {
await h?.close();
await qb?.close();
for (const t of [lib, lib2, cache]) await t?.cleanup();
});
async function release(entityId: string, title: string) {
const t = makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: Math.floor(Math.random() * 1e6) });
const id = await seedRelease(db(), { siteId: base.siteId, entityId, title, size: SIZE, hash: t.hash });
torrents.set(id, t.bytes);
return id;
}
const privateGroups = (subId: string) =>
db()
.selectFrom("rule_group")
.selectAll()
.where(sql<string>`scope ->> 'subscriptionId'`, "=", subId)
.orderBy("created_at")
.execute();
const slotsOf = (subId: string) =>
db().selectFrom("acquisition_slot").selectAll().where("subscription_id", "=", subId).orderBy("position").execute();
it("包含 / 排除 → 私有规则组:规则页不列出;修改 revision+1;清空软删;删除订阅软删,重新订阅重建", async () => {
const e = await seedEntity(db(), { kind: "series", title: "私有组剧", year: 2025 });
const c = await h.api("POST", "/subscriptions", {
mediaEntityId: e,
mediaKind: "series",
params: { include: "中字|CHS", exclude: ["HDTV"] },
});
expect(c.status, JSON.stringify(c.body)).toBe(201);
const id = c.body.data.id as string;
let gs = await privateGroups(id);
expect(gs).toHaveLength(1);
expect(gs[0]).toMatchObject({ kind: "acquisition", deleted_at: null, revision: 1, ladder_required: false });
expect(gs[0]!.hard).toEqual({ include: ["中字|CHS"], exclude: ["HDTV"] });
const list = await h.api("GET", "/rules?limit=200");
expect(list.body.data.items.some((g: { id: string }) => g.id === gs[0]!.id)).toBe(false);
const p = await h.api("PATCH", `/subscriptions/${id}`, {
params: { include: ["中字"], exclude: ["HDTV", "CAM"] },
});
expect(p.status).toBe(200);
gs = await privateGroups(id);
expect(gs[0]).toMatchObject({ revision: 2, hard: { include: ["中字"], exclude: ["HDTV", "CAM"] } });
await h.api("PATCH", `/subscriptions/${id}`, { params: {} });
expect((await privateGroups(id))[0]!.deleted_at).not.toBeNull();
await h.api("PATCH", `/subscriptions/${id}`, { params: { exclude: ["CAM"] } });
const live = (await privateGroups(id)).filter((g) => !g.deleted_at);
expect(live).toHaveLength(1);
expect(live[0]!.hard).toEqual({ exclude: ["CAM"] });
const del = await h.api("POST", "/subscriptions/batch", { ids: [id], action: "delete" });
expect(del.body.data.results[0].ok).toBe(true);
expect((await privateGroups(id)).filter((g) => !g.deleted_at)).toHaveLength(0);
const re = await h.api("POST", `/subscriptions/${id}/resubscribe`);
expect(re.status, JSON.stringify(re.body)).toBe(200);
const again = (await privateGroups(id)).filter((g) => !g.deleted_at);
expect(again).toHaveLength(1);
expect(again[0]!.hard).toEqual({ exclude: ["CAM"] });
});
it("评估链纳入私有组:包含没命中 / 排除命中都拒绝;命中包含建计划;与版本目标自己的包含取 AND", async () => {
const e = await seedEntity(db(), { kind: "movie", title: "Filter Film", year: 2024 });
const c = await h.api("POST", "/subscriptions", {
mediaEntityId: e,
mediaKind: "movie",
params: { include: ["CHS"], exclude: ["HDTV"], resolution: "1080P" },
});
expect(c.status, JSON.stringify(c.body)).toBe(201);
const [slot] = await slotsOf(c.body.data.id);
expect(slot).toBeTruthy();
const noInc = await release(e, "Filter.Film.2024.1080p.WEB-DL.x264-GRP");
const r1 = await evaluateAcquisition(h.d, { sourceReleaseId: noInc });
expect(r1.plans).toHaveLength(0);
const cand = await db()
.selectFrom("artifact")
.select("content")
.where("title", "=", "candidate_evaluation")
.where(sql<boolean>`content->>'sourceReleaseId' = ${noInc}`)
.executeTakeFirstOrThrow();
expect(JSON.stringify(cand.content)).toContain("include_missed");
const excl = await release(e, "Filter.Film.2024.1080p.HDTV.CHS.x264-GRP");
expect((await evaluateAcquisition(h.d, { sourceReleaseId: excl })).plans).toHaveLength(0);
const ok = await release(e, "Filter.Film.2024.1080p.WEB-DL.CHS.x264-GRP");
const r3 = await evaluateAcquisition(h.d, { sourceReleaseId: ok });
expect(r3.plans, JSON.stringify(r3)).toHaveLength(1);
// 版本目标自己也要求 "GRP2":与私有组的 CHS 取 AND
await db().updateTable("acquisition_plan").set({ status: "cancelled" }).where("id", "=", r3.plans[0]!).execute();
await db()
.updateTable("acquisition_slot")
.set({
status: "empty",
hard_conditions: JSON.stringify({ resolutions: ["1080p", "1080i"], include: ["GRP2"] }),
})
.where("id", "=", slot!.id)
.execute();
const onlyGrp = await release(e, "Filter.Film.2024.1080p.WEB-DL.x264-GRP2");
expect((await evaluateAcquisition(h.d, { sourceReleaseId: onlyGrp })).plans).toHaveLength(0);
const both = await release(e, "Filter.Film.2024.1080p.WEB-DL.CHS.x264-GRP2");
expect((await evaluateAcquisition(h.d, { sourceReleaseId: both })).plans).toHaveLength(1);
});
it("MP 分辨率 / 来源 / 特效:新建时建默认版本目标承载预设;改预设覆盖同一字段,手改过的字段保留;无法识别 400", async () => {
const e = await seedEntity(db(), { kind: "series", title: "预设剧", year: 2025 });
const bad = await h.api("POST", "/subscriptions", {
mediaEntityId: e,
mediaKind: "series",
params: { resolution: "480p" },
});
expect(bad.status).toBe(400);
const c = await h.api("POST", "/subscriptions", {
mediaEntityId: e,
mediaKind: "series",
params: { resolution: "4K|2160p|x2160", quality: "WEB-?DL|WEB-?RIP", effect: "杜比视界" },
});
expect(c.status, JSON.stringify(c.body)).toBe(201);
const id = c.body.data.id as string;
expect(c.body.data.params).toMatchObject({ resolution: "4k", source: "web", effect: "dv" });
expect(c.body.data.params.quality).toBeUndefined();
let [slot] = await slotsOf(id);
expect(slot).toMatchObject({ name: "默认版本" });
expect(slot!.hard_conditions).toEqual({
resolutions: ["2160p"],
sources: ["WEB-DL", "WEBRip"],
hdr: { anyOf: ["DV"] },
});
expect(slot!.soft_preferences).toEqual({ sources: ["WEB-DL", "WEBRip"], hdr: ["DV", "HDR10+", "HDR10"] });
// 手改来源
await db()
.updateTable("acquisition_slot")
.set({ hard_conditions: JSON.stringify({ ...(slot!.hard_conditions as object), sources: ["WEB-DL"] }) })
.where("id", "=", slot!.id)
.execute();
const p = await h.api("PATCH", `/subscriptions/${id}`, { params: { resolution: "1080p" } });
expect(p.status).toBe(200);
[slot] = await slotsOf(id);
// 分辨率换成新预设;特效预设去掉 → 删;来源手改过 → 保留
expect(slot!.hard_conditions).toEqual({ resolutions: ["1080p", "1080i"], sources: ["WEB-DL"] });
expect(slot!.soft_preferences).toEqual({});
// 预设 UHD / Atmos 进私有组包含,与表单包含取 AND
await h.api("PATCH", `/subscriptions/${id}`, { params: { source: "uhd", include: ["中字"] } });
const g = (await privateGroups(id)).find((x) => !x.deleted_at)!;
expect((g.hard as { include: string[] }).include).toHaveLength(1);
expect(new RegExp((g.hard as { include: string[] }).include[0]!, "i").test("X UHD 中字")).toBe(true);
expect(new RegExp((g.hard as { include: string[] }).include[0]!, "i").test("X 中字")).toBe(false);
});
it("下载提交:订阅指定下载器与保存路径(本机路径经映射)→ 计划评估与 qB savepath;映射不到 → 不建计划", async () => {
const inst2 = await db()
.insertInto("downloader_instance")
.values({ name: "qb-c1a-2", base_url: qb.url, username: "admin", settings: JSON.stringify({ savePath: "/dl" }) })
.returning("id")
.executeTakeFirstOrThrow();
await db()
.insertInto("path_mapping")
.values({
view_kind: "downloader",
downloader_instance_id: inst2.id,
remote_prefix: "/dl",
local_prefix: cache.root,
})
.execute();
const e = await seedEntity(db(), { kind: "movie", title: "Save Path Film", year: 2023 });
const c = await h.api("POST", "/subscriptions", {
mediaEntityId: e,
mediaKind: "movie",
params: { downloaderInstanceId: inst2.id, downloadSavePath: `${cache.root}/movies`, resolution: "1080p" },
});
expect(c.status, JSON.stringify(c.body)).toBe(201);
const rel = await release(e, "Save.Path.Film.2023.1080p.WEB-DL.x264-GRP");
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel });
expect(ev.plans, JSON.stringify(ev)).toHaveLength(1);
const plan = await db()
.selectFrom("acquisition_plan")
.selectAll()
.where("id", "=", ev.plans[0]!)
.executeTakeFirstOrThrow();
expect(plan.evaluation).toMatchObject({ instanceId: inst2.id, savePath: "/dl/movies" });
const s = await submitDownload(h.d, plan.id);
expect(s.status, JSON.stringify(s)).toBe("submitted");
const dt = await db()
.selectFrom("download_task")
.selectAll()
.where("id", "=", s.downloadTaskId!)
.executeTakeFirstOrThrow();
expect(dt.instance_id).toBe(inst2.id);
expect(dt.save_path).toBe("/dl/movies");
// 映射不到的保存路径:不建计划,理由可见
const e2 = await seedEntity(db(), { kind: "movie", title: "Unmapped Film", year: 2023 });
await h.api("POST", "/subscriptions", {
mediaEntityId: e2,
mediaKind: "movie",
params: { downloaderInstanceId: inst2.id, downloadSavePath: "/nowhere", resolution: "1080p" },
});
const rel2 = await release(e2, "Unmapped.Film.2023.1080p.WEB-DL.x264-GRP");
const ev2 = await evaluateAcquisition(h.d, { sourceReleaseId: rel2 });
expect(ev2.plans).toHaveLength(0);
expect(ev2.skipped).toContainEqual({ sourceReleaseId: rel2, reason: "download_save_path_unmapped" });
});
it("库根:作品还没有路径绑定时,整理按订阅指定的库根;已有绑定仍按绑定", async () => {
const root2 = await db()
.insertInto("library_root")
.values({ name: "c1a-root2", media_kind: "series", local_path: lib2.root, management_mode: "managed" })
.returning("id")
.executeTakeFirstOrThrow();
const e = await seedEntity(db(), { kind: "movie", title: "Root Pick", year: 2021 });
const dry = () =>
planImport(h.d, {
sourcePaths: [cache.path("Root.Pick.2021.1080p.BluRay.x264-GRP.mkv")],
mediaEntityId: e,
action: "copy",
trigger: "manual",
actorType: "user",
dryRun: true,
});
const before = await dry();
expect(before.plan?.libraryRootId, JSON.stringify(before)).toBe(base.rootId);
const c = await h.api("POST", "/subscriptions", {
mediaEntityId: e,
mediaKind: "movie",
params: { libraryRootId: root2.id },
});
expect(c.status).toBe(201);
const after = await dry();
expect(after.plan?.libraryRootId, JSON.stringify(after)).toBe(root2.id);
});
it("重置:取消未提交的计划与下载已不在的计划,保留仍在下载的;清延后;排立即重评;已完成 409", async () => {
const e = await seedEntity(db(), { kind: "series", title: "Reset Show", year: 2024 });
const c = await h.api("POST", "/subscriptions", {
mediaEntityId: e,
mediaKind: "series",
params: { resolution: "1080p" },
});
const id = c.body.data.id as string;
const [slot] = await slotsOf(id);
const r1 = await release(e, "Reset.Show.S01E01.1080p.WEB-DL.x264-GRP");
const r2 = await release(e, "Reset.Show.S01E02.1080p.WEB-DL.x264-GRP");
const r3 = await release(e, "Reset.Show.S01E03.1080p.WEB-DL.x264-GRP");
const [p1] = (await evaluateAcquisition(h.d, { sourceReleaseId: r1 })).plans;
const [p2] = (await evaluateAcquisition(h.d, { sourceReleaseId: r2 })).plans;
const [p3] = (await evaluateAcquisition(h.d, { sourceReleaseId: r3 })).plans;
expect([p1, p2, p3].every(Boolean)).toBe(true);
// p1 在下载;p2 已提交但下载任务被移除;p3 只预留
expect((await submitDownload(h.d, p1!)).status).toBe("submitted");
const s2 = await submitDownload(h.d, p2!);
await db()
.updateTable("download_task")
.set({ removed_at: new Date() })
.where("id", "=", s2.downloadTaskId!)
.execute();
const r2b = await release(e, "Reset.Show.S01E02.1080p.WEB-DL.x265-OTHER");
await evaluateAcquisition(h.d, { sourceReleaseId: r2b });
expect(await db().selectFrom("slot_deferral").select("id").where("slot_id", "=", slot!.id).execute()).toHaveLength(
1,
);
h.engine.enqueued.length = 0;
const res = await h.api("POST", `/subscriptions/${id}/reset`);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body.data).toMatchObject({
subscriptionId: id,
keptPlans: [p1],
clearedDeferrals: 1,
reevaluated: true,
});
expect([...res.body.data.releasedPlans].sort()).toEqual([p2, p3].sort());
const statuses = await db()
.selectFrom("acquisition_plan")
.select(["id", "status", "status_reason"])
.where("id", "in", [p1!, p2!, p3!])
.execute();
expect(statuses.find((x) => x.id === p1)?.status).toBe("submitted");
expect(statuses.filter((x) => x.status === "cancelled").map((x) => x.status_reason)).toEqual([
"subscription_reset",
"subscription_reset",
]);
expect(await db().selectFrom("slot_deferral").select("id").where("slot_id", "=", slot!.id).execute()).toHaveLength(
0,
);
expect((await slotsOf(id))[0]!.next_evaluate_at).not.toBeNull();
expect(h.engine.enqueued.some((q) => q.task === "evaluateAcquisition")).toBe(true);
const audit = await db()
.selectFrom("audit_event")
.select("action")
.where("target_id", "=", id)
.where("action", "=", "subscription.reset")
.execute();
expect(audit).toHaveLength(1);
await db().updateTable("subscription").set({ status: "completed" }).where("id", "=", id).execute();
expect((await h.api("POST", `/subscriptions/${id}/reset`)).status).toBe(409);
});
it("日历:集播出日期同日同季聚合,状态取主版本;没有播出日期列 unknown;电影取上映日期;跨度超限 400", async () => {
const today = new Date().toISOString().slice(0, 10);
const addDays = (n: number) => new Date(Date.now() + n * 86_400_000).toISOString().slice(0, 10);
const e = await seedEntity(db(), { kind: "series", title: "日历剧", year: 2026 });
const season = await db()
.insertInto("content_unit")
.values({
media_entity_id: e,
kind: "season",
season_number: 1,
status: "confirmed",
episode_total: 4,
episode_total_source: "tmdb",
})
.returning("id")
.executeTakeFirstOrThrow();
const ep = async (n: number, air: string | null) =>
(
await db()
.insertInto("content_unit")
.values({
media_entity_id: e,
parent_unit_id: season.id,
kind: "episode",
season_number: 1,
episode_number: n,
status: "confirmed",
air_date: air,
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
const e1 = await ep(1, addDays(-3));
await ep(2, addDays(-3));
await ep(3, addDays(5));
await ep(4, null);
const c = await h.api("POST", "/subscriptions", {
mediaEntityId: e,
mediaKind: "series",
seasonUnitId: season.id,
params: { resolution: "1080p" },
});
expect(c.status, JSON.stringify(c.body)).toBe(201);
const subId = c.body.data.id as string;
// E01 已入库(单版本目标:作品下资产都算)
const a = await db()
.insertInto("media_asset")
.values({
media_entity_id: e,
kind: "video",
size_bytes: SIZE,
media_info: JSON.stringify({ _umt: { quality: { resolution: "1080p", source: "WEB-DL" } } }),
})
.returning("id")
.executeTakeFirstOrThrow();
await db().insertInto("asset_unit_coverage").values({ media_asset_id: a.id, content_unit_id: e1 }).execute();
const m = await seedEntity(db(), { kind: "movie", title: "日历片", year: 2026 });
await db()
.updateTable("media_entity")
.set({ metadata: JSON.stringify({ releaseDate: { value: addDays(10), source: "tmdb" } }) })
.where("id", "=", m)
.execute();
await h.api("POST", "/subscriptions", { mediaEntityId: m, mediaKind: "movie" });
const m2 = await seedEntity(db(), { kind: "movie", title: "无日期片", year: 2026 });
await h.api("POST", "/subscriptions", { mediaEntityId: m2, mediaKind: "movie" });
const cal = await h.api("GET", `/subscriptions/calendar?from=${addDays(-10)}&to=${addDays(20)}`);
expect(cal.status, JSON.stringify(cal.body)).toBe(200);
const mine = cal.body.data.entries.filter((x: { subscriptionId: string }) => x.subscriptionId === subId);
expect(mine).toEqual([
expect.objectContaining({
date: addDays(-3),
seasonNumber: 1,
episodes: [1, 2],
label: "S01E01–E02",
state: "partial",
have: 1,
total: 2,
}),
expect.objectContaining({ date: addDays(5), episodes: [3], state: "upcoming", have: 0, total: 1 }),
]);
expect(cal.body.data.unknown).toContainEqual(
expect.objectContaining({ subscriptionId: subId, seasonNumber: 1, episodes: [4] }),
);
expect(cal.body.data.entries).toContainEqual(
expect.objectContaining({ title: "日历片", date: addDays(10), label: "上映", state: "upcoming" }),
);
expect(cal.body.data.unknown).toContainEqual(expect.objectContaining({ title: "无日期片", episodes: [] }));
const movies = await h.api("GET", `/subscriptions/calendar?from=${addDays(-10)}&to=${addDays(20)}&mediaKind=movie`);
expect(movies.body.data.entries.every((x: { mediaKind: string }) => x.mediaKind === "movie")).toBe(true);
expect((await h.api("GET", `/subscriptions/calendar?from=${today}&to=${addDays(90)}`)).status).toBe(400);
expect((await h.api("GET", `/subscriptions/calendar?from=${today}&to=${addDays(-1)}`)).status).toBe(400);
});
});
+22
View File
@@ -629,6 +629,28 @@ export const Api = {
}, },
R, R,
), ),
/** 日历(静态路径,须先于 /subscriptions/{id} 注册) */
calendar: ep({
method: "get",
path: "/subscriptions/calendar",
scope: "read",
summary:
"订阅日历:追踪中订阅范围内集的播出日期(同日同季聚合)与入库状态,电影取上映日期;没有播出日期的列在 unknown。from/to 为 YYYY-MM-DD,跨度不超过 62 天",
query: z.object({
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
mediaKind: policy.SubscriptionMediaKind.optional(),
}),
response: policy.SubscriptionCalendar,
}),
reset: ep({
method: "post",
path: "/subscriptions/{id}/reset",
scope: "admin",
summary: "重置进度:取消已预留未提交、下载任务已不在的计划,清延后记录,立即重评;仍在下载的计划与已入库资产保留",
params: IdParam,
response: policy.SubscriptionResetResult,
}),
/** 总览:在 crud 列表上加分组/排序/筛选,条目带卡片派生字段,另给各分组计数(docs/design/subscriptions.md §1) */ /** 总览:在 crud 列表上加分组/排序/筛选,条目带卡片派生字段,另给各分组计数(docs/design/subscriptions.md §1) */
list: ep({ list: ep({
method: "get", method: "get",
+77 -1
View File
@@ -97,12 +97,32 @@ export const FillBudget = z
.strict(); .strict();
export type FillBudget = z.infer<typeof FillBudget>; export type FillBudget = z.infer<typeof FillBudget>;
/** MP 分辨率(resolution)→ 版本目标硬条件 resolutions */
export const SubscriptionResolution = z.enum(["4k", "1080p", "720p"]);
export type SubscriptionResolution = z.infer<typeof SubscriptionResolution>;
/** MP 资源质量(quality / resource_type)→ 版本目标硬条件 sources / codecs;uhd 写进订阅私有规则组的包含 */
export const SubscriptionSourcePreset = z.enum([
"bluray_disc",
"remux",
"bluray",
"uhd",
"web",
"hdtv",
"h265",
"h264",
]);
export type SubscriptionSourcePreset = z.infer<typeof SubscriptionSourcePreset>;
/** MP 特效(effect)→ 版本目标硬条件 hdr + 软偏好 hdr 顺序;atmos 写进订阅私有规则组的包含 */
export const SubscriptionEffect = z.enum(["dv", "hdr", "sdr", "atmos"]);
export type SubscriptionEffect = z.infer<typeof SubscriptionEffect>;
/** /**
* subscription.params 的已知键(JSON 列,其余键原样保留): * subscription.params 的已知键(JSON 列,其余键原样保留):
* siteIds 站点范围;episodeRange 集范围(对照 MP start/total_episode);searchIntervalHours 主动搜索周期(0 = 关); * siteIds 站点范围;episodeRange 集范围(对照 MP start/total_episode);searchIntervalHours 主动搜索周期(0 = 关);
* stopAfter 截止时间;downloaderInstanceId 指定下载器;ruleGroupIds 规则组;keywords 追加检索词; * stopAfter 截止时间;downloaderInstanceId 指定下载器;ruleGroupIds 规则组;keywords 追加检索词;
* include/exclude 订阅级关键词正则(作用于全部版本目标);libraryRootId 库根;downloadSavePath 下载保存路径; * include/exclude 订阅级关键词正则(作用于全部版本目标);libraryRootId 库根;downloadSavePath 下载保存路径;
* autoFill/fillBudget 多版本自动补全。 * autoFill/fillBudget 多版本自动补全;resolution/source/effect 质量预设(MP resolution/quality/effect)。
* include/exclude 保存时同步为订阅私有规则组(scope.subscriptionId,随订阅删除),评估只读规则组。
* 写入时兼容 MP 字段与旧键(sites/downloader/save_path/start_episode/filter_groups/keyword/autoFillVersions…), * 写入时兼容 MP 字段与旧键(sites/downloader/save_path/start_episode/filter_groups/keyword/autoFillVersions…),
* 服务端规范化后只存本表列出的键,见 docs/runbook/subscriptions.md。 * 服务端规范化后只存本表列出的键,见 docs/runbook/subscriptions.md。
*/ */
@@ -130,6 +150,10 @@ export const SubscriptionParams = z
downloadSavePath: z.string().min(1).max(1000).optional(), downloadSavePath: z.string().min(1).max(1000).optional(),
autoFill: AutoFillMode.optional(), autoFill: AutoFillMode.optional(),
fillBudget: FillBudget.optional(), fillBudget: FillBudget.optional(),
/** MP resolution / quality / effect:保存时写到该订阅自己的版本目标(硬条件 + 软偏好) */
resolution: SubscriptionResolution.optional(),
source: SubscriptionSourcePreset.optional(),
effect: SubscriptionEffect.optional(),
}) })
.passthrough(); .passthrough();
export type SubscriptionParams = z.infer<typeof SubscriptionParams>; export type SubscriptionParams = z.infer<typeof SubscriptionParams>;
@@ -424,6 +448,56 @@ export const SubscriptionBatchResult = z.object({
}); });
export type SubscriptionBatchResult = z.infer<typeof SubscriptionBatchResult>; export type SubscriptionBatchResult = z.infer<typeof SubscriptionBatchResult>;
/** 重置进度:清在途标记(未提交/已失联的计划)与延后记录,重新评估;不删资产 */
export const SubscriptionResetResult = z.object({
subscriptionId: Id,
/** 被取消的计划(已预留未提交,或下载任务已不在) */
releasedPlans: z.array(Id),
/** 下载器里仍在下载的计划:保留 */
keptPlans: z.array(Id),
clearedDeferrals: z.number().int(),
reevaluated: z.boolean(),
});
export type SubscriptionResetResult = z.infer<typeof SubscriptionResetResult>;
/** 日历条目状态:have 已入库 / partial 部分 / missing 未入库 / upcoming 未播出 */
export const CalendarEntryState = z.enum(["have", "partial", "missing", "upcoming"]);
export type CalendarEntryState = z.infer<typeof CalendarEntryState>;
export const SubscriptionCalendarEntry = z.object({
/** YYYY-MM-DD */
date: z.string(),
subscriptionId: Id,
mediaEntityId: Id.nullable(),
title: z.string(),
mediaKind: SubscriptionMediaKind,
/** 电影为 null */
seasonNumber: z.number().int().nullable(),
episodes: z.array(z.number().int()),
/** 如 "S01E03–E04";电影 "上映" */
label: z.string(),
state: CalendarEntryState,
have: z.number().int(),
total: z.number().int(),
});
export type SubscriptionCalendarEntry = z.infer<typeof SubscriptionCalendarEntry>;
export const SubscriptionCalendar = z.object({
from: z.string(),
to: z.string(),
entries: z.array(SubscriptionCalendarEntry),
/** 范围内季集没有播出日期(TMDB 未给或未刷新)的订阅:播出日期未知 */
unknown: z.array(
z.object({
subscriptionId: Id,
title: z.string(),
mediaKind: SubscriptionMediaKind,
seasonNumber: z.number().int().nullable(),
/** 没有播出日期的集;电影为空 */
episodes: z.array(z.number().int()),
}),
),
});
export type SubscriptionCalendar = z.infer<typeof SubscriptionCalendar>;
/** 手动补全某版本:不受自动补全开关与补全预算限制(仍受全局预算/缓存卷阈值) */ /** 手动补全某版本:不受自动补全开关与补全预算限制(仍受全局预算/缓存卷阈值) */
export const SlotFillRequest = z.object({ unitKeys: z.array(z.string().min(1)).max(500).optional() }); export const SlotFillRequest = z.object({ unitKeys: z.array(z.string().min(1)).max(500).optional() });
export type SlotFillRequest = z.infer<typeof SlotFillRequest>; export type SlotFillRequest = z.infer<typeof SlotFillRequest>;
@@ -776,6 +850,8 @@ export const RuleGroup = EntityBase.extend({
/** 语义指纹(kind + scope + 层 + 硬排除),停用/删除后写抑制记录 */ /** 语义指纹(kind + scope + 层 + 硬排除),停用/删除后写抑制记录 */
fingerprint: z.string(), fingerprint: z.string(),
evidence: JsonObject, evidence: JsonObject,
/** 结构化硬条件(include / exclude 正则等),评估时并入版本目标硬条件;订阅私有组用它存表单的包含 / 排除 */
hard: JsonObject.optional(),
/** 非 null = 属于该类默认链,按此排序 */ /** 非 null = 属于该类默认链,按此排序 */
defaultOrder: z.number().int().nullable(), defaultOrder: z.number().int().nullable(),
/** 引用本组的订阅缺省是否自动补全其他版本;null = 不表态 */ /** 引用本组的订阅缺省是否自动补全其他版本;null = 不表态 */
@@ -0,0 +1,50 @@
import type { Kysely } from "kysely";
import { execDdl } from "../src/migration-helpers.ts";
// C1-A 订阅遗留收尾(docs/design/subscriptions.md §3、§6.1):
// - rule_group.hard:规则组的结构化硬条件(SlotHardConditions 子集,目前用 include / exclude 正则),
// 评估时并入版本目标硬条件。订阅表单的"包含 / 排除"写成订阅私有规则组(scope.subscriptionId),
// 不在规则页列表显示,随订阅删除(软删)。
// - 存量:params 里已有 include / exclude 的未取消订阅各建一个私有组(params 原样保留,表单回显用)。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
alter table rule_group add column hard jsonb not null default '{}'::jsonb check (jsonb_typeof(hard) = 'object');
create index rule_group_private_idx on rule_group ((scope ->> 'subscriptionId'))
where deleted_at is null and scope ? 'subscriptionId';
insert into rule_group (name, description, kind, scope, ladder_required, source, fingerprint, hard)
select '订阅私有 ' || left(s.id::text, 8) || '-' || substr(s.id::text, 10, 4),
'订阅表单的包含 / 排除,随订阅删除',
'acquisition',
jsonb_build_object('subscriptionId', s.id::text),
false,
'manual',
'private:' || s.id::text || ':' || md5(coalesce(s.params -> 'include', '[]'::jsonb)::text || coalesce(s.params -> 'exclude', '[]'::jsonb)::text),
jsonb_strip_nulls(jsonb_build_object(
'include', case when jsonb_typeof(s.params -> 'include') = 'array' and jsonb_array_length(s.params -> 'include') > 0
then s.params -> 'include' end,
'exclude', case when jsonb_typeof(s.params -> 'exclude') = 'array' and jsonb_array_length(s.params -> 'exclude') > 0
then s.params -> 'exclude' end))
from subscription s
where s.status <> 'cancelled'
and ((jsonb_typeof(s.params -> 'include') = 'array' and jsonb_array_length(s.params -> 'include') > 0)
or (jsonb_typeof(s.params -> 'exclude') = 'array' and jsonb_array_length(s.params -> 'exclude') > 0));
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
delete from rule_group where scope ? 'subscriptionId' and fingerprint like 'private:%';
drop index if exists rule_group_private_idx;
alter table rule_group drop column if exists hard;
`,
);
}
+1
View File
@@ -1190,6 +1190,7 @@ export interface RuleGroupTable {
deleted_at: Timestamptz | null; deleted_at: Timestamptz | null;
created_at: GeneratedTimestamptz; created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz; updated_at: GeneratedTimestamptz;
hard: Generated<Json>;
} }
export interface ScheduledJobTable { export interface ScheduledJobTable {