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:
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> = {
|
||||
|
||||
Reference in new issue
Block a user