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:
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();
|
||||
|
||||
@@ -292,6 +292,8 @@ describe.skipIf(!hasPg)("E7 常规剧集纵切", () => {
|
||||
.select(["episode_number", "status"])
|
||||
.where("media_entity_id", "=", entityId)
|
||||
.where("kind", "=", "episode")
|
||||
// 追踪订阅的最新季(S2/S3)由元数据刷新按 TMDB 季详情建集单元(日历用);这里只看包所在的 S1
|
||||
.where("season_number", "=", 1)
|
||||
.orderBy("episode_number")
|
||||
.execute();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -629,6 +629,28 @@ export const Api = {
|
||||
},
|
||||
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) */
|
||||
list: ep({
|
||||
method: "get",
|
||||
|
||||
@@ -97,12 +97,32 @@ export const FillBudget = z
|
||||
.strict();
|
||||
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 列,其余键原样保留):
|
||||
* siteIds 站点范围;episodeRange 集范围(对照 MP start/total_episode);searchIntervalHours 主动搜索周期(0 = 关);
|
||||
* stopAfter 截止时间;downloaderInstanceId 指定下载器;ruleGroupIds 规则组;keywords 追加检索词;
|
||||
* 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…),
|
||||
* 服务端规范化后只存本表列出的键,见 docs/runbook/subscriptions.md。
|
||||
*/
|
||||
@@ -130,6 +150,10 @@ export const SubscriptionParams = z
|
||||
downloadSavePath: z.string().min(1).max(1000).optional(),
|
||||
autoFill: AutoFillMode.optional(),
|
||||
fillBudget: FillBudget.optional(),
|
||||
/** MP resolution / quality / effect:保存时写到该订阅自己的版本目标(硬条件 + 软偏好) */
|
||||
resolution: SubscriptionResolution.optional(),
|
||||
source: SubscriptionSourcePreset.optional(),
|
||||
effect: SubscriptionEffect.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export type SubscriptionParams = z.infer<typeof SubscriptionParams>;
|
||||
@@ -424,6 +448,56 @@ export const SubscriptionBatchResult = z.object({
|
||||
});
|
||||
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 type SlotFillRequest = z.infer<typeof SlotFillRequest>;
|
||||
@@ -776,6 +850,8 @@ export const RuleGroup = EntityBase.extend({
|
||||
/** 语义指纹(kind + scope + 层 + 硬排除),停用/删除后写抑制记录 */
|
||||
fingerprint: z.string(),
|
||||
evidence: JsonObject,
|
||||
/** 结构化硬条件(include / exclude 正则等),评估时并入版本目标硬条件;订阅私有组用它存表单的包含 / 排除 */
|
||||
hard: JsonObject.optional(),
|
||||
/** 非 null = 属于该类默认链,按此排序 */
|
||||
defaultOrder: z.number().int().nullable(),
|
||||
/** 引用本组的订阅缺省是否自动补全其他版本;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;
|
||||
`,
|
||||
);
|
||||
}
|
||||
@@ -1190,6 +1190,7 @@ export interface RuleGroupTable {
|
||||
deleted_at: Timestamptz | null;
|
||||
created_at: GeneratedTimestamptz;
|
||||
updated_at: GeneratedTimestamptz;
|
||||
hard: Generated<Json>;
|
||||
}
|
||||
|
||||
export interface ScheduledJobTable {
|
||||
|
||||
Reference in new issue
Block a user