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

@@ -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> = {