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