feat(ao,sources): W5-C subscription completion, Slot × unit reservation, deferral, pack file selection, active search

- completion by season episode_total ledger (applySlotEvent verify_passed hook overrides organize's
  complete): unknown total keeps tracking and records suspected-missing; pending never completes;
  once / continuous current-season completion writes subscription_history
- evaluation: missing = release units ∩ episodeRange − covered − in-flight; series slots may hold
  several unit-disjoint in-flight plans; overlapping/busy releases are deferred (slot_deferral) and
  re-evaluated when a plan ends (next_evaluate_at due) or on the periodic slot re-evaluation
- per-unit upgrade via slot_unit ledger; site scope (params.siteIds); site facts in evaluation context
- season packs: after submit read qB files and setFilePriority(0) for covered/in-flight episodes;
  whole pack with recorded reason when undeterminable; removed downloads release their reservation
- searchSubscription task (title/aliases × enabled sites via liveSearch, snapshot + rate limiter):
  on create, every searchIntervalHours (default 24h) and POST /subscriptions/{id}/search
- refreshMetadata writes season totals (only grows, floored by covered, lock respected) and turns
  metadata_pending subscriptions active; plan loop refreshes tracked series every 6h
- endpoints: subscription progress, episode-total, subscription-history; e9 continuous-series e2e

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:34:07 +08:00
1 parent 1dd460d359
commit 7aa6cac717
14 files changed
+2553 -190

No files matched your search

@@ -0,0 +1,82 @@
import type { CandidateSeason } 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 不代表总数)。
export interface SeasonTotalsResult {
seasons: { season: number; total: number | null; known: boolean; changed: boolean }[];
/** 本次后该作品的追踪订阅从 pending 转 active 的数量 */
activated: number;
}
async function ensureSeason(exec: Exec, entityId: string, season: number, name: string | null): Promise<string> {
const hit = await exec
.selectFrom("content_unit")
.select("id")
.where("media_entity_id", "=", entityId)
.where("kind", "=", "season")
.where("season_number", "=", season)
.where("status", "!=", "retired")
.orderBy("created_at")
.executeTakeFirst();
if (hit) return hit.id;
return (
await exec
.insertInto("content_unit")
.values({ media_entity_id: entityId, kind: "season", season_number: season, status: "confirmed", title: name })
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
export async function applySeasonTotals(
exec: Exec,
entityId: string,
seasons: readonly CandidateSeason[],
now: Date,
): Promise<SeasonTotalsResult> {
const out: SeasonTotalsResult = { seasons: [], activated: 0 };
for (const s of seasons) {
if (s.seasonNumber < 1) continue; // S00 特别篇不计入正片季
const unitId = await ensureSeason(exec, entityId, s.seasonNumber, s.name);
const cur = await exec
.selectFrom("content_unit")
.select(["episode_total", "episode_total_locked"])
.where("id", "=", unitId)
.forUpdate()
.executeTakeFirstOrThrow();
const covered = await sql<{ max: number | null }>`
select max(u.episode_number)::int as max from asset_unit_coverage c
join content_unit u on u.id = c.content_unit_id
join media_asset a on a.id = c.media_asset_id
where u.media_entity_id = ${entityId} and u.kind = 'episode' and u.season_number = ${s.seasonNumber}
and a.status = 'active'`.execute(exec);
const r = resolveEpisodeTotal({
previous: cur.episode_total,
fresh: s.episodeCount,
coveredMax: covered.rows[0]?.max ?? null,
locked: cur.episode_total_locked,
});
if (r.changed)
await exec
.updateTable("content_unit")
.set({ episode_total: r.total, episode_total_source: r.source, episode_total_at: now })
.where("id", "=", unitId)
.execute();
out.seasons.push({ season: s.seasonNumber, total: r.total, known: r.known, changed: r.changed });
}
// TMDB 已就绪:因元数据未就绪而 pending 的订阅转 active(仍按总集数是否已知决定能否完成)
const act = await exec
.updateTable("subscription")
.set({ status: "active", status_reason: "metadata_ready" })
.where("media_entity_id", "=", entityId)
.where("status", "=", "pending")
.where("status_reason", "=", "metadata_pending")
.executeTakeFirst();
out.activated = Number(act.numUpdatedRows ?? 0);
return out;
}
@@ -6,6 +6,7 @@ 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";
// refreshMetadata:字段级刷新(REQ §7.3)。来源值写 metadata_field;最终值 = 覆盖(锁)> 已锁字段保持 > 来源值。
// 只更新 media_entity 的展示字段与别名,不触碰 path_binding(路径绑定不随元数据变化)。
@@ -34,6 +35,8 @@ export interface RefreshResult {
reason?: string;
changedFields: string[];
lockedFields: string[];
/** 剧集:季总集数账本刷新结果(W5-C) */
seasons?: SeasonTotalsResult["seasons"];
}
export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string): Promise<RefreshResult> {
@@ -95,8 +98,13 @@ export async function refreshMetadata(rt: SourcesRuntime, mediaEntityId: string)
.execute();
await seedAliases(trx, id, detail);
const r = await project(trx, id, now);
// 季总集数账本:只升;回退不低于已覆盖最高集;锁定不改(订阅完成判定据此)
const totals =
ident.entity_type === "tv" && detail.seasons?.length
? await applySeasonTotals(trx, id, detail.seasons, now)
: null;
await trx.updateTable("media_entity").set({ last_metadata_refresh_at: now }).where("id", "=", id).execute();
return { status: "refreshed" as const, ...r };
return { status: "refreshed" as const, ...r, ...(totals ? { seasons: totals.seasons } : {}) };
});
}
@@ -5,6 +5,7 @@ import { ingestFeed } from "./service/feeds.ts";
import { refreshMetadata } from "./service/metadata.ts";
import { resolveRelease } from "./service/resolve.ts";
import { snapshotSite } from "./service/snapshot.ts";
import { liveSearch } from "./service/workbench.ts";
// 持久任务:输入只放业务 ID,处理器读业务表。外部请求失败的退避/熔断由适配器状态与业务表承担,
// 凭据失效返回正常结果(不重试);只有意外错误(DB、TMDB 网络)抛出交给引擎重试。
@@ -53,6 +54,14 @@ export function registerSourcesTasks(registry: TaskRegistry, rt: () => SourcesRu
retryPolicy: NETWORK_RETRY,
handler: (t) => t.step.run("refresh", () => refreshMetadata(rt(), t.input.mediaEntityId)),
}),
defineTask({
name: "searchSubscription",
version: 1,
inputSchema: z.object({ subscriptionId: id, manual: z.boolean().optional() }),
retryPolicy: NETWORK_RETRY,
handler: (t) =>
t.step.run("search", () => searchSubscription(rt(), t.input.subscriptionId, { manual: !!t.input.manual })),
}),
defineTask({
name: "snapshotSite",
version: 1,
@@ -70,6 +79,106 @@ export function registerSourcesTasks(registry: TaskRegistry, rt: () => SourcesRu
);
}
// ── 订阅主动搜索(W5-C,对照 MP SubscribeSearchOwner):按作品标题/原名/别名在已开启站点搜索 ──
/** 主动搜索周期缺省 24h;subscription.params.searchIntervalHours 覆盖(0 = 关闭周期搜索) */
export const DEFAULT_SEARCH_INTERVAL_HOURS = 24;
/** 每次最多用几个检索词(标题、原名、别名),控制站点请求量 */
export const SEARCH_MAX_KEYWORDS = 3;
export interface SubscriptionSearchResult {
status: "ok" | "skipped";
reason?: string;
keywords: string[];
sites: string[];
searches: number;
errors: { siteId: string; keyword: string; error: string }[];
nextSearchAt: string | null;
}
/**
* 主动搜索:对订阅站点范围内已开启且健康的站点,按标题/原名/别名经站点适配器 search(共享限流器、实时补查合并与失败退避,
* 记 site_snapshot kind=live_search);新结果 upsert 为 source_release 并发 release.observed → 识别 → release.resolved →
* evaluateAcquisition(与 RSS 同一路径);已识别的旧结果由本任务末尾入队的订阅重评覆盖。
*/
export async function searchSubscription(
rt: SourcesRuntime,
subscriptionId: string,
opts: { manual?: boolean } = {},
): Promise<SubscriptionSearchResult> {
const { db } = rt.ctx;
const none = { keywords: [], sites: [], searches: 0, errors: [], nextSearchAt: null };
const sub = await db.selectFrom("subscription").selectAll().where("id", "=", subscriptionId).executeTakeFirst();
if (!sub) return { status: "skipped", reason: "not_found", ...none };
if (!opts.manual && sub.status !== "active" && sub.status !== "pending")
return { status: "skipped", reason: `subscription_${sub.status}`, ...none };
if (!sub.media_entity_id) return { status: "skipped", reason: "no_entity", ...none };
const automation = await rt.ctx.settings.get("automation");
if (!opts.manual && (!automation.enabled || automation.readOnlyReconcile))
return { status: "skipped", reason: "automation_blocked", ...none };
const entity = await db
.selectFrom("media_entity")
.select(["id", "title", "original_title"])
.where("id", "=", sub.media_entity_id)
.executeTakeFirst();
if (!entity) return { status: "skipped", reason: "entity_not_found", ...none };
const aliases = await db
.selectFrom("title_alias")
.select(["alias", "kind"])
.where("media_entity_id", "=", entity.id)
.where("content_unit_id", "is", null)
.orderBy("created_at")
.limit(20)
.execute();
const seen = new Set<string>();
const keywords: string[] = [];
for (const k of [entity.title, entity.original_title, ...aliases.map((a) => a.alias)]) {
const v = (k ?? "").trim();
const norm = v.toLowerCase();
if (!v || seen.has(norm)) continue;
seen.add(norm);
keywords.push(v);
if (keywords.length >= SEARCH_MAX_KEYWORDS) break;
}
const params = (sub.params ?? {}) as { siteIds?: string[]; searchIntervalHours?: number };
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();
const errors: SubscriptionSearchResult["errors"] = [];
let searches = 0;
for (const site of sites) {
for (const kw of keywords) {
searches++;
const r = await liveSearch(rt, site, kw).catch((e) => ({ error: (e as Error).message, coalesced: false }));
if (r.error) errors.push({ siteId: site.id, keyword: kw, error: r.error });
}
}
const hours = params.searchIntervalHours ?? DEFAULT_SEARCH_INTERVAL_HOURS;
const now = rt.now();
const next = hours > 0 ? new Date(now.getTime() + hours * 3600_000) : null;
await db
.updateTable("subscription")
.set({ last_searched_at: now, next_search_at: next })
.where("id", "=", sub.id)
.execute();
// 已识别的旧结果:按订阅重评(新结果走 release.observed → resolved → evaluate)
await rt.ctx.engine
.enqueue(
"evaluateAcquisition",
{ subscriptionId: sub.id },
{ idempotencyKey: `search-eval:${sub.id}:${now.getTime()}`, traceId: sub.id },
)
.catch(() => {});
return {
status: "ok",
keywords,
sites: sites.map((s) => s.id),
searches,
errors,
nextSearchAt: next ? next.toISOString() : null,
};
}
type P = Record<string, unknown>;
export const sourcesOutboxRoutes: Record<string, OutboxRouteFn> = {