merge: fix/f-acquisition
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
commit
1aebb519ef
10 files changed
+1033
-192
No files matched your search
@@ -2,7 +2,6 @@ import {
|
||||
type AcquisitionSlot,
|
||||
Api,
|
||||
DEFAULT_SEARCH_INTERVAL_HOURS,
|
||||
type SlotTemplate,
|
||||
type Subscription,
|
||||
type SubscriptionGroup,
|
||||
type SubscriptionHistory,
|
||||
@@ -16,7 +15,7 @@ import type { Context } from "hono";
|
||||
import type { AuthEnv } from "../../../http/auth.ts";
|
||||
import { ApiException } from "../../../http/errors.ts";
|
||||
import type { ModuleApp } from "../../types.ts";
|
||||
import { createSlotsFromTemplates, publishedAcquisitionPolicies } from "../services/acquisition.ts";
|
||||
import { ensureSubscriptionSlot } from "../services/acquisition.ts";
|
||||
import type { AoDeps, Exec } from "../services/common.ts";
|
||||
import {
|
||||
buildSubscriptionModels,
|
||||
@@ -107,9 +106,6 @@ async function assertNoDuplicate(
|
||||
if (hit) conflict(`该作品同一季已有订阅:${hit.id}`);
|
||||
}
|
||||
|
||||
/** 没有收录策略模板、表单给了质量预设时,新订阅按这个模板建一个收录版本(预设随后写入) */
|
||||
const DEFAULT_SLOT_TEMPLATE: SlotTemplate = { name: "默认版本" };
|
||||
|
||||
/**
|
||||
* 保存后应用表单的派生写入:包含 / 排除 → 订阅私有规则组;质量预设(MP resolution / quality / effect)→
|
||||
* 该订阅自己的收录版本;MP total_episode → 季总集数;best_version → 收录版本升级策略。
|
||||
@@ -376,33 +372,8 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
if (r.media_entity_id) {
|
||||
const has = await trx
|
||||
.selectFrom("acquisition_slot")
|
||||
.select("id")
|
||||
.where("media_entity_id", "=", r.media_entity_id)
|
||||
.executeTakeFirst();
|
||||
if (!has) {
|
||||
const pols = await publishedAcquisitionPolicies(trx);
|
||||
const pol =
|
||||
(body.policyRevisionId ? pols.find((p) => p.row.id === body.policyRevisionId) : undefined) ??
|
||||
pols.find((p) => p.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?.rules.defaultSlots?.length ? pol.row.id : null,
|
||||
templates,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 订阅总有自己的收录版本:策略模板,没有就建一个默认版本(质量预设随后由 applyDerived 写入)
|
||||
await ensureSubscriptionSlot(trx, r);
|
||||
await applyDerived(trx, r, norm, now);
|
||||
return r;
|
||||
});
|
||||
@@ -458,6 +429,8 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
||||
.where("id", "=", params.id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
// 旧订阅没有自己的收录版本:编辑时补建(质量预设 / 升级策略才有地方写)
|
||||
if (r.status !== "cancelled") await ensureSubscriptionSlot(trx, r);
|
||||
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;
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import type {
|
||||
AcquisitionPolicyRules,
|
||||
AcquisitionPriority,
|
||||
AcquisitionSlot,
|
||||
CandidateEvaluation,
|
||||
SlotTemplate,
|
||||
} from "@umt/contracts";
|
||||
import type { AcquisitionPolicyRules, AcquisitionPriority, CandidateEvaluation, SlotTemplate } from "@umt/contracts";
|
||||
import type { AcquisitionSlotRow, MediaEntityRow, SourceReleaseRow, SubscriptionRow } from "@umt/db";
|
||||
import { sql } from "@umt/db";
|
||||
import {
|
||||
@@ -12,15 +6,14 @@ import {
|
||||
checkBudget,
|
||||
computeUnitGaps,
|
||||
determineMode,
|
||||
type EvaluationContext,
|
||||
effectiveLadder,
|
||||
episodeKey,
|
||||
evaluateCandidate,
|
||||
improvableUnits,
|
||||
type ParsedRelease,
|
||||
parseRelease,
|
||||
priorityOf,
|
||||
type QualityFacts,
|
||||
rankForSlot,
|
||||
ruleChainOf,
|
||||
type SlotEvaluation,
|
||||
type SlotSpec,
|
||||
@@ -29,8 +22,8 @@ import {
|
||||
transition,
|
||||
unitsOverlap,
|
||||
} from "@umt/domain";
|
||||
import { fromParsedColumns, slotSpecFromRow, toContractSlotEvaluation } from "@umt/mapping";
|
||||
import { hashJson, jsonb, startOfDay, toApi } from "../util.ts";
|
||||
import { toContractSlotEvaluation } from "@umt/mapping";
|
||||
import { hashJson, jsonb, startOfDay } from "../util.ts";
|
||||
import {
|
||||
type AoDeps,
|
||||
automationBlock,
|
||||
@@ -45,24 +38,32 @@ import {
|
||||
recordCandidate,
|
||||
} from "./common.ts";
|
||||
import { autoFillCheck, type ForceFill } from "./evaluate-autofill.ts";
|
||||
import { entityOriginalLanguage, loadRuleCatalog, mergeHard, type RuleCatalog, resolveRuleChain } from "./rules.ts";
|
||||
import { contextOf, episodeResolutionOf, parsedOf, unitKeysOf } from "./release-facts.ts";
|
||||
import { entityOriginalLanguage, loadRuleCatalog, type RuleCatalog } from "./rules.ts";
|
||||
import {
|
||||
clearDeferral,
|
||||
clearReleaseDeferrals,
|
||||
clearSubscriptionDeferrals,
|
||||
gateRetryDelayMs,
|
||||
manualInflightPlans,
|
||||
rangeOf,
|
||||
recordDeferral,
|
||||
scheduleSlotReevaluate,
|
||||
scheduleSlotRetry,
|
||||
seasonInScope,
|
||||
seasonLedgers,
|
||||
slotCoveredUnits,
|
||||
slotInflightPlans,
|
||||
slotSpecWithRules,
|
||||
subParams,
|
||||
subSeasonOf,
|
||||
TRACKING_SUB_STATUSES,
|
||||
} from "./subscriptions.ts";
|
||||
import { applySlotPreset } from "./subscriptions-params.ts";
|
||||
import { decideVolume, loadVolumes } from "./volumes.ts";
|
||||
|
||||
export { contextOf, parsedOf, unitKeysOf } from "./release-facts.ts";
|
||||
|
||||
// evaluateAcquisition:release.resolved → 匹配订阅/Slot → 模式 → 确定性评估 → 预算/缓存卷 → 冻结计划并预留。
|
||||
// 拒绝与被挡住的原因都写成候选评估记录(artifact),供作品详情"为什么选/不选"。
|
||||
|
||||
@@ -98,34 +99,6 @@ const PRIORITY: Record<ReturnType<typeof priorityOf>, AcquisitionPriority> = {
|
||||
|
||||
const BUSY: readonly string[] = ["reserved", "downloading", "verifying"];
|
||||
|
||||
interface StoredEpisodeResolution {
|
||||
method?: string;
|
||||
special?: string;
|
||||
canonical?: { season: number; episodes: { start: number; end: number } } | null;
|
||||
}
|
||||
|
||||
const episodeResolutionOf = (r: Pick<SourceReleaseRow, "episode_resolution">): StoredEpisodeResolution | null =>
|
||||
r.episode_resolution && typeof r.episode_resolution === "object"
|
||||
? (r.episode_resolution as StoredEpisodeResolution)
|
||||
: null;
|
||||
|
||||
/**
|
||||
* source_release → ParsedRelease(解析版本过期时按原标题重解析;站点体积覆盖解析体积)。
|
||||
* 重解析时把集数解析的规范季集叠加回去(parsed 列本来就是规范值,重解析不能丢映射)。
|
||||
*/
|
||||
export function parsedOf(r: SourceReleaseRow): ParsedRelease {
|
||||
let p = fromParsedColumns({ parsed: (r.parsed ?? {}) as Record<string, unknown>, parserVersion: r.parser_version });
|
||||
if (!p) {
|
||||
p = parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {});
|
||||
const c = episodeResolutionOf(r)?.canonical;
|
||||
if (c) {
|
||||
const { seasonEnd: _drop, ...rest } = p;
|
||||
p = { ...rest, season: c.season, episodes: { ...c.episodes }, seasonInferred: false };
|
||||
}
|
||||
}
|
||||
return r.size_bytes !== null ? { ...p, sizeBytes: r.size_bytes } : p;
|
||||
}
|
||||
|
||||
/** 集数待确认(无法换算 / 规则失效)或映射为特别篇 / 花絮 / 忽略:不参与订阅评估 */
|
||||
export function episodeUnresolved(r: Pick<SourceReleaseRow, "episode_resolution">): boolean {
|
||||
const res = episodeResolutionOf(r);
|
||||
@@ -145,16 +118,6 @@ export async function currentFacts(exec: Exec, assetId: string | null): Promise<
|
||||
return { ...q, ...(a.size_bytes !== null ? { sizeBytes: a.size_bytes } : {}) };
|
||||
}
|
||||
|
||||
/** 单元键:有 content_unit 用其 id,没有时用 s{季}e{集} 合成键(追更判定两侧同口径) */
|
||||
export function unitKeysOf(p: Pick<ParsedRelease, "season" | "episodes" | "airDate">): string[] {
|
||||
if (p.airDate) return [`d${p.airDate}`];
|
||||
if (!p.episodes) return [];
|
||||
const s = p.season ?? 1;
|
||||
const out: string[] = [];
|
||||
for (let e = p.episodes.start; e <= p.episodes.end && out.length < 500; e++) out.push(`s${s}e${e}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function releaseUnits(
|
||||
exec: Exec,
|
||||
entityId: string,
|
||||
@@ -227,9 +190,24 @@ export async function createSlotsFromTemplates(
|
||||
policyRevisionId: string | null;
|
||||
templates: readonly SlotTemplate[];
|
||||
},
|
||||
): Promise<void> {
|
||||
for (const [i, t] of input.templates.entries()) {
|
||||
): Promise<number> {
|
||||
// 收录版本名在作品内唯一(media_entity_id, content_unit_id, name):旧订阅留下的同名版本不挡新订阅,改名加序号
|
||||
const taken = new Set(
|
||||
(
|
||||
await exec
|
||||
.selectFrom("acquisition_slot")
|
||||
.select("name")
|
||||
.where("media_entity_id", "=", input.entityId)
|
||||
.where("content_unit_id", "is", null)
|
||||
.execute()
|
||||
).map((r) => r.name),
|
||||
);
|
||||
let n = 0;
|
||||
for (const [i, t] of input.templates.entries()) {
|
||||
let name = t.name;
|
||||
for (let k = 2; taken.has(name) && k < 1000; k++) name = `${t.name} ${k}`;
|
||||
taken.add(name);
|
||||
const row = await exec
|
||||
.insertInto("acquisition_slot")
|
||||
.values({
|
||||
// 同一事务建的收录版本 created_at 相同:按模板顺序给 position,第一个为主版本
|
||||
@@ -237,7 +215,7 @@ export async function createSlotsFromTemplates(
|
||||
media_entity_id: input.entityId,
|
||||
subscription_id: input.subscriptionId,
|
||||
policy_revision_id: input.policyRevisionId,
|
||||
name: t.name,
|
||||
name,
|
||||
hard_conditions: jsonb(t.hardConditions),
|
||||
soft_preferences: jsonb(t.softPreferences),
|
||||
fallback: jsonb(t.fallback),
|
||||
@@ -246,8 +224,65 @@ export async function createSlotsFromTemplates(
|
||||
rule_group_ids: JSON.stringify(t.ruleGroupIds ?? []),
|
||||
})
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.execute();
|
||||
.returning("id")
|
||||
.executeTakeFirst();
|
||||
if (row) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** 追踪中的订阅里没有自己收录版本的,逐个补建;返回是否建了 */
|
||||
async function backfillSubscriptionSlots(
|
||||
db: AoDeps["ctx"]["db"],
|
||||
subs: readonly SubscriptionRow[],
|
||||
slots: readonly Pick<AcquisitionSlotRow, "subscription_id">[],
|
||||
): Promise<boolean> {
|
||||
let made = 0;
|
||||
for (const s of subs)
|
||||
if (s.media_entity_id && !slots.some((x) => x.subscription_id === s.id)) made += await ensureSlotLocked(db, s);
|
||||
return made > 0;
|
||||
}
|
||||
|
||||
/** 评估路径上补建:锁订阅行,免得并发的两次评估各建一个 */
|
||||
function ensureSlotLocked(db: AoDeps["ctx"]["db"], sub: SubscriptionRow): Promise<number> {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
await trx.selectFrom("subscription").select("id").where("id", "=", sub.id).forUpdate().execute();
|
||||
return ensureSubscriptionSlot(trx, sub);
|
||||
});
|
||||
}
|
||||
|
||||
/** 订阅没有收录策略模板时的隐式缺省收录版本:无硬条件,表单的质量预设 / 包含排除随后由订阅参数写入 */
|
||||
export const DEFAULT_SLOT_TEMPLATE: SlotTemplate = { name: "默认版本" };
|
||||
|
||||
/**
|
||||
* 订阅总有属于自己的收录版本(H1):没有时按收录策略模板(指定的或第一个带 defaultSlots 的已发布策略)建,
|
||||
* 没有模板就建一个隐式「默认版本」,再把订阅表单的质量预设写进去。作品已有别的订阅(旧季、已取消)的
|
||||
* 收录版本不算——评估时订阅只用自己的收录版本。返回新建个数(已有则 0)。
|
||||
*/
|
||||
export async function ensureSubscriptionSlot(
|
||||
exec: Exec,
|
||||
sub: Pick<SubscriptionRow, "id" | "media_entity_id" | "params" | "policy_revision_id">,
|
||||
): Promise<number> {
|
||||
if (!sub.media_entity_id) return 0;
|
||||
const own = await exec
|
||||
.selectFrom("acquisition_slot")
|
||||
.select("id")
|
||||
.where("subscription_id", "=", sub.id)
|
||||
.executeTakeFirst();
|
||||
if (own) return 0;
|
||||
const pols = await publishedAcquisitionPolicies(exec);
|
||||
const pol =
|
||||
(sub.policy_revision_id ? pols.find((p) => p.row.id === sub.policy_revision_id) : undefined) ??
|
||||
pols.find((p) => p.rules.defaultSlots?.length);
|
||||
const fromPolicy = !!pol?.rules.defaultSlots?.length;
|
||||
const n = await createSlotsFromTemplates(exec, {
|
||||
entityId: sub.media_entity_id,
|
||||
subscriptionId: sub.id,
|
||||
policyRevisionId: fromPolicy ? pol!.row.id : null,
|
||||
templates: fromPolicy ? pol!.rules.defaultSlots! : [DEFAULT_SLOT_TEMPLATE],
|
||||
});
|
||||
if (n > 0) await applySlotPreset(exec, { subscriptionId: sub.id, prev: {}, next: subParams(sub) });
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -291,6 +326,13 @@ interface Ctx {
|
||||
forceFill?: ForceFill | undefined;
|
||||
}
|
||||
|
||||
/** 作品原语言(规则组 CNSUB/CNVOI 放行),一次评估内缓存 */
|
||||
function entityLang(c: Ctx, entityId: string): Promise<string | undefined> {
|
||||
c.langs ??= new Map();
|
||||
if (!c.langs.has(entityId)) c.langs.set(entityId, entityOriginalLanguage(c.d.ctx.db, entityId));
|
||||
return c.langs.get(entityId)!;
|
||||
}
|
||||
|
||||
async function note(c: Ctx, base: Omit<CandidateEvaluation, "evaluatedAt">) {
|
||||
await recordCandidate(c.d.ctx.db, { ...base, evaluatedAt: c.now.toISOString() });
|
||||
}
|
||||
@@ -314,32 +356,49 @@ export async function evaluateAcquisition(d: AoDeps, input: EvaluateInput): Prom
|
||||
c.out.skipped.push({ sourceReleaseId: input.sourceReleaseId, reason: "release_not_found" });
|
||||
return c.out;
|
||||
}
|
||||
await evaluateRelease(c, rel, input.contentUnitIds ?? [], input.acquisitionSlotId);
|
||||
await evaluateRelease(c, rel, input.contentUnitIds ?? [], { slotId: input.acquisitionSlotId });
|
||||
return c.out;
|
||||
}
|
||||
// 按 Slot / 订阅重评:取该作品最近已识别的发布
|
||||
let entityId: string | null = null;
|
||||
return reevaluate(c, input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按收录版本 / 订阅重评时最多取的已识别资源条数(按首次出现倒序)。原来只看最近 50 条,长剧、多发布组时早期集
|
||||
* 进不了评估(M5);放宽到 500 后先在内存里按订阅季范围、已覆盖单元过滤,只把可能有用的逐条评估
|
||||
* (每条评估约十几次查询,500 条全过一遍也在几秒量级)。
|
||||
*/
|
||||
export const REEVALUATE_MAX_RELEASES = 500;
|
||||
|
||||
/**
|
||||
* 按收录版本 / 订阅重评:延后的资源 + 作品已识别资源(按范围过滤),先用 rankForSlot 按主收录版本择优排序
|
||||
* (pri → 来源 / 分辨率等级 → 软偏好),再逐条评估预留(M4:同一单元多个合格资源时 pri 最高的先占位,
|
||||
* 其余延后 / 洗版判断)。逐条到来的 RSS 资源仍走 evaluateRelease,不受影响。
|
||||
*/
|
||||
async function reevaluate(c: Ctx, input: EvaluateInput): Promise<EvaluateOutcome> {
|
||||
const db = c.d.ctx.db;
|
||||
let sub: SubscriptionRow | undefined;
|
||||
let focus: AcquisitionSlotRow[] = [];
|
||||
if (input.acquisitionSlotId) {
|
||||
entityId =
|
||||
(
|
||||
await db
|
||||
.selectFrom("acquisition_slot")
|
||||
.select("media_entity_id")
|
||||
.where("id", "=", input.acquisitionSlotId)
|
||||
.executeTakeFirst()
|
||||
)?.media_entity_id ?? null;
|
||||
focus = await db.selectFrom("acquisition_slot").selectAll().where("id", "=", input.acquisitionSlotId).execute();
|
||||
const sid = focus[0]?.subscription_id;
|
||||
sub = sid ? await db.selectFrom("subscription").selectAll().where("id", "=", sid).executeTakeFirst() : undefined;
|
||||
} else if (input.subscriptionId) {
|
||||
entityId =
|
||||
(
|
||||
await db
|
||||
.selectFrom("subscription")
|
||||
.select("media_entity_id")
|
||||
.where("id", "=", input.subscriptionId)
|
||||
.executeTakeFirst()
|
||||
)?.media_entity_id ?? null;
|
||||
sub = await db.selectFrom("subscription").selectAll().where("id", "=", input.subscriptionId).executeTakeFirst();
|
||||
if (sub && (TRACKING_SUB_STATUSES as readonly string[]).includes(sub.status)) await ensureSlotLocked(db, sub);
|
||||
if (sub)
|
||||
focus = await db
|
||||
.selectFrom("acquisition_slot")
|
||||
.selectAll()
|
||||
.where("subscription_id", "=", sub.id)
|
||||
.orderBy("position")
|
||||
.orderBy("created_at")
|
||||
.execute();
|
||||
}
|
||||
const entityId = focus[0]?.media_entity_id ?? sub?.media_entity_id ?? null;
|
||||
if (!entityId) return c.out;
|
||||
// 先评该 Slot 延后的发布(按集号升序,连续更新的集依次补上),再评最近已识别的发布
|
||||
const entity = await resolveEntity(db, entityId);
|
||||
if (!entity) return c.out;
|
||||
// 先评该 Slot 延后的发布(按集号升序,连续更新的集依次补上),再评作品其它已识别的发布
|
||||
const deferred = input.acquisitionSlotId
|
||||
? await db
|
||||
.selectFrom("slot_deferral as sd")
|
||||
@@ -362,14 +421,93 @@ export async function evaluateAcquisition(d: AoDeps, input: EvaluateInput): Prom
|
||||
.where("resolution_status", "=", "resolved")
|
||||
.where("availability", "=", "active")
|
||||
.orderBy("first_seen_at", "desc")
|
||||
.limit(50)
|
||||
.limit(REEVALUATE_MAX_RELEASES)
|
||||
.execute();
|
||||
for (const r of [...deferred, ...rels.filter((x) => !seen.has(x.id))])
|
||||
await evaluateRelease(c, r, [], input.acquisitionSlotId);
|
||||
const fresh = await inScopeReleases(
|
||||
c,
|
||||
entity,
|
||||
sub,
|
||||
focus,
|
||||
rels.filter((x) => !seen.has(x.id)),
|
||||
);
|
||||
const ordered = await rankReleases(c, entity, sub, focus[0], [...deferred, ...fresh]);
|
||||
const scope = input.acquisitionSlotId ? { slotId: input.acquisitionSlotId } : sub ? { subscriptionId: sub.id } : {};
|
||||
for (const r of ordered) await evaluateRelease(c, r, [], scope);
|
||||
return c.out;
|
||||
}
|
||||
|
||||
async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readonly string[], onlySlotId?: string) {
|
||||
/**
|
||||
* 重评候选的预过滤(M5):订阅季范围外的(与评估同口径)不评;剧集单集资源的单元在每个目标收录版本里都已覆盖、
|
||||
* 且这些收录版本都没开升级时不评。季包 / 综艺按日期 / 电影原样保留,交给逐条评估。
|
||||
*/
|
||||
async function inScopeReleases(
|
||||
c: Ctx,
|
||||
entity: MediaEntityRow,
|
||||
sub: SubscriptionRow | undefined,
|
||||
focus: readonly AcquisitionSlotRow[],
|
||||
rels: SourceReleaseRow[],
|
||||
): Promise<SourceReleaseRow[]> {
|
||||
if (entity.kind === "movie") return rels;
|
||||
const db = c.d.ctx.db;
|
||||
const subSeason = sub ? await subSeasonOf(db, sub) : null;
|
||||
const upgrading = focus.some((s) => !!(s.upgrade_threshold as { enabled?: boolean } | null)?.enabled);
|
||||
const slotCount = focus.length
|
||||
? Number(
|
||||
(
|
||||
await db
|
||||
.selectFrom("acquisition_slot")
|
||||
.select((eb) => eb.fn.countAll<number>().as("n"))
|
||||
.where("media_entity_id", "=", entity.id)
|
||||
.executeTakeFirstOrThrow()
|
||||
).n,
|
||||
)
|
||||
: 0;
|
||||
const covered =
|
||||
focus.length && !upgrading
|
||||
? await Promise.all(focus.map((s) => slotCoveredUnits(db, s, { entityWide: slotCount <= 1 })))
|
||||
: [];
|
||||
return rels.filter((r) => {
|
||||
const p = parsedOf(r);
|
||||
const season = p.season ?? (p.episodes ? 1 : undefined);
|
||||
if (sub && season !== undefined && !seasonInScope(sub, subSeason, season)) return false;
|
||||
if (!covered.length || !p.episodes) return true;
|
||||
const keys = unitKeysOf(p);
|
||||
return !covered.every((cv) => keys.every((k) => cv.keys.has(k)));
|
||||
});
|
||||
}
|
||||
|
||||
/** 按主收录版本择优排序(rankForSlot);没有收录版本时保持原顺序 */
|
||||
async function rankReleases(
|
||||
c: Ctx,
|
||||
entity: MediaEntityRow,
|
||||
sub: SubscriptionRow | undefined,
|
||||
slot: AcquisitionSlotRow | undefined,
|
||||
rels: SourceReleaseRow[],
|
||||
): Promise<SourceReleaseRow[]> {
|
||||
if (!slot || rels.length < 2) return rels;
|
||||
c.rules ??= loadRuleCatalog(c.d.ctx.db);
|
||||
const spec = slotSpecWithRules(await c.rules, slot, sub, entity);
|
||||
const lang = spec.rules ? await entityLang(c, entity.id) : undefined;
|
||||
const byParsed = new Map<ParsedRelease, SourceReleaseRow>();
|
||||
const cands = rels.map((r) => {
|
||||
const p = parsedOf(r);
|
||||
byParsed.set(p, r);
|
||||
return p;
|
||||
});
|
||||
return rankForSlot(spec, cands, null, (p) => ({
|
||||
...contextOf(byParsed.get(p)!, c.now),
|
||||
...(lang ? { originalLanguage: lang } : {}),
|
||||
})).map((x) => byParsed.get(x.candidate)!);
|
||||
}
|
||||
|
||||
/** 评估范围:只评某个收录版本,或只评某个订阅的收录版本;缺省评作品的全部收录版本 */
|
||||
interface EvalScope {
|
||||
slotId?: string | undefined;
|
||||
subscriptionId?: string | undefined;
|
||||
}
|
||||
|
||||
async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readonly string[], scope: EvalScope = {}) {
|
||||
const onlySlotId = scope.slotId;
|
||||
const { d, now } = c;
|
||||
const db = d.ctx.db;
|
||||
const skip = (reason: string) => c.out.skipped.push({ sourceReleaseId: rel.id, reason });
|
||||
@@ -442,6 +580,14 @@ async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readon
|
||||
.executeTakeFirst();
|
||||
if (!grant) return skip("no_automation_grant");
|
||||
await db.transaction().execute(async (trx) => {
|
||||
// 同一作品的几条资源并发评估时只建一个新作品订阅(否则后建的订阅收录版本同名冲突,被补建成默认版本)
|
||||
await sql`select pg_advisory_xact_lock(hashtextextended(${`new_title:${entity.id}`}, 0))`.execute(trx);
|
||||
const exists = await trx
|
||||
.selectFrom("subscription")
|
||||
.select("id")
|
||||
.where("media_entity_id", "=", entity.id)
|
||||
.executeTakeFirst();
|
||||
if (exists) return;
|
||||
const sub = await trx
|
||||
.insertInto("subscription")
|
||||
.values({
|
||||
@@ -495,12 +641,22 @@ async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readon
|
||||
const activeSubs = new Map<string, SubscriptionRow>(
|
||||
subs.filter((s) => (TRACKING_SUB_STATUSES as readonly string[]).includes(s.status)).map((s) => [s.id, s]),
|
||||
);
|
||||
// 一次性补建:旧版本建的订阅可能没有自己的收录版本(H1),首次评估时补上
|
||||
if (await backfillSubscriptionSlots(db, [...activeSubs.values()], slots))
|
||||
slots = await db
|
||||
.selectFrom("acquisition_slot")
|
||||
.selectAll()
|
||||
.where("media_entity_id", "=", entity.id)
|
||||
.orderBy("position")
|
||||
.orderBy("created_at")
|
||||
.execute();
|
||||
const tracked = activeSubs.size > 0 || slots.some((s) => s.subscription_id === null);
|
||||
const releaseSeason = parsed.season ?? (parsed.episodes ? 1 : undefined);
|
||||
|
||||
let planned: string | null = null;
|
||||
for (const slot of slots) {
|
||||
if (onlySlotId && slot.id !== onlySlotId) continue;
|
||||
if (scope.subscriptionId && slot.subscription_id !== scope.subscriptionId) continue;
|
||||
const sub = slot.subscription_id ? activeSubs.get(slot.subscription_id) : undefined;
|
||||
if (slot.subscription_id && !sub) {
|
||||
// 订阅已暂停/完成/取消:不评估,也不留延后行
|
||||
@@ -523,15 +679,8 @@ async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readon
|
||||
.executeTakeFirst();
|
||||
if (!seasonOk) continue;
|
||||
}
|
||||
// 仅本季:发布季号与订阅季不一致则跳过
|
||||
if (sub?.season_scope === "current_season" && sub.season_unit_id && releaseSeason !== undefined) {
|
||||
const su = await db
|
||||
.selectFrom("content_unit")
|
||||
.select("season_number")
|
||||
.where("id", "=", sub.season_unit_id)
|
||||
.executeTakeFirst();
|
||||
if (su?.season_number !== null && su?.season_number !== undefined && su.season_number !== releaseSeason) continue;
|
||||
}
|
||||
// 订阅季范围:仅本季 = 同季,接续后续季 = 不早于订阅季(与覆盖矩阵同口径)
|
||||
if (sub && releaseSeason !== undefined && !seasonInScope(sub, await subSeasonOf(db, sub), releaseSeason)) continue;
|
||||
c.out.evaluated++;
|
||||
const outcome = await evaluateForSlot(c, {
|
||||
rel,
|
||||
@@ -583,33 +732,6 @@ async function clearEpisodeUnresolvedNotes(c: Ctx, sourceReleaseId: string, enti
|
||||
and content->'reasons' ? 'episode_unresolved'`.execute(c.d.ctx.db);
|
||||
}
|
||||
|
||||
/** 站点字段 → 评估上下文(做种数、促销下载系数、副标题/标签、站点 id、发布后分钟数) */
|
||||
export function contextOf(rel: SourceReleaseRow, now: Date = new Date()): EvaluationContext {
|
||||
const promo = (rel.promotion ?? {}) as { kind?: string; raw?: { downloadFactor?: number | null } };
|
||||
const factor =
|
||||
typeof promo.raw?.downloadFactor === "number"
|
||||
? promo.raw.downloadFactor
|
||||
: promo.kind === "free" || promo.kind === "free2up"
|
||||
? 0
|
||||
: promo.kind === "50pctdown" || promo.kind === "50pctdown2up"
|
||||
? 0.5
|
||||
: promo.kind === "30pctdown"
|
||||
? 0.3
|
||||
: promo.kind === "none" || promo.kind === "2up"
|
||||
? 1
|
||||
: undefined;
|
||||
return {
|
||||
siteId: rel.site_id,
|
||||
...(rel.subtitle ? { subtitle: rel.subtitle } : {}),
|
||||
labels: rel.tags_raw ?? [],
|
||||
...(rel.seeders !== null && rel.seeders !== undefined ? { seeders: rel.seeders } : {}),
|
||||
...(factor !== undefined ? { downloadVolumeFactor: factor } : {}),
|
||||
...(rel.source_published_at
|
||||
? { publishedMinutesAgo: Math.max(0, (now.getTime() - rel.source_published_at.getTime()) / 60_000) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
interface SlotInput {
|
||||
rel: SourceReleaseRow;
|
||||
entity: MediaEntityRow;
|
||||
@@ -642,37 +764,26 @@ 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 = slotSpecFromRow(toApi<AcquisitionSlot>(slot));
|
||||
const ctx = contextOf(rel, now);
|
||||
// 规则组:收录版本 ruleGroupIds → 订阅 ruleGroupIds → 订阅默认链;开升级叠加洗版默认链
|
||||
// 规则组:收录版本 ruleGroupIds → 订阅 ruleGroupIds → 订阅默认链;开升级叠加洗版默认链;私有组包含 / 排除并入硬条件
|
||||
c.rules ??= loadRuleCatalog(db);
|
||||
const chain = resolveRuleChain(await c.rules, {
|
||||
kind: "acquisition",
|
||||
slotRuleGroupIds: (slot.rule_group_ids ?? []) as string[],
|
||||
subRuleGroupIds: params.ruleGroupIds,
|
||||
upgrade: !!spec.upgrade?.enabled,
|
||||
mediaKind: subKindOf(p.entity.kind),
|
||||
entityId: p.entity.id,
|
||||
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();
|
||||
if (!c.langs.has(p.entity.id)) c.langs.set(p.entity.id, entityOriginalLanguage(db, p.entity.id));
|
||||
const lang = await c.langs.get(p.entity.id);
|
||||
const spec = slotSpecWithRules(await c.rules, slot, sub, p.entity, rel.site_id);
|
||||
const ctx = contextOf(rel, now);
|
||||
if (spec.rules) {
|
||||
const lang = await entityLang(c, p.entity.id);
|
||||
if (lang) ctx.originalLanguage = lang;
|
||||
}
|
||||
const inflight = await slotInflightPlans(db, slot.id);
|
||||
// 在途 = 本收录版本的计划 + 同作品手动下载中的计划(M6)
|
||||
const manual = await manualInflightPlans(db, p.entity.id);
|
||||
const inflight = [...(await slotInflightPlans(db, slot.id)), ...manual];
|
||||
const inflightKeys = inflight.flatMap((x) => x.unitKeys);
|
||||
const deferWithRetry = async (slotId: string, releaseId: string, reason: string, unitKeys: string[]) => {
|
||||
const attempts = await recordDeferral(db, { slotId, sourceReleaseId: releaseId, reason, unitKeys });
|
||||
await scheduleSlotRetry(db, slotId, new Date(now.getTime() + gateRetryDelayMs(attempts)));
|
||||
};
|
||||
const defer = async (reason: string, unitKeys: string[], mode: AcquisitionMode | null = null) => {
|
||||
await recordDeferral(db, { slotId: slot.id, sourceReleaseId: rel.id, reason, unitKeys });
|
||||
const attempts = await recordDeferral(db, { slotId: slot.id, sourceReleaseId: rel.id, reason, unitKeys });
|
||||
// 手动计划结束不会触发本收录版本重评:有手动在途时按次数退避排一次重评
|
||||
if (manual.length) await scheduleSlotRetry(db, slot.id, new Date(now.getTime() + gateRetryDelayMs(attempts)));
|
||||
await note(c, {
|
||||
...base,
|
||||
decision: "deferred",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { SourceReleaseRow } from "@umt/db";
|
||||
import { type EvaluationContext, type ParsedRelease, parseRelease } from "@umt/domain";
|
||||
import { fromParsedColumns } from "@umt/mapping";
|
||||
|
||||
// 资源行 → 评估输入(解析结果、站点上下文)。评估(acquisition.ts)与完成判定(subscriptions.ts)共用,单独成文件免得两者循环引用。
|
||||
|
||||
interface StoredEpisodeResolution {
|
||||
method?: string;
|
||||
special?: string;
|
||||
canonical?: { season: number; episodes: { start: number; end: number } } | null;
|
||||
}
|
||||
|
||||
export const episodeResolutionOf = (r: Pick<SourceReleaseRow, "episode_resolution">): StoredEpisodeResolution | null =>
|
||||
r.episode_resolution && typeof r.episode_resolution === "object"
|
||||
? (r.episode_resolution as StoredEpisodeResolution)
|
||||
: null;
|
||||
|
||||
/**
|
||||
* source_release → ParsedRelease(解析版本过期时按原标题重解析;站点体积覆盖解析体积)。
|
||||
* 重解析时把集数解析的规范季集叠加回去(parsed 列本来就是规范值,重解析不能丢映射)。
|
||||
*/
|
||||
export function parsedOf(r: SourceReleaseRow): ParsedRelease {
|
||||
let p = fromParsedColumns({ parsed: (r.parsed ?? {}) as Record<string, unknown>, parserVersion: r.parser_version });
|
||||
if (!p) {
|
||||
p = parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {});
|
||||
const c = episodeResolutionOf(r)?.canonical;
|
||||
if (c) {
|
||||
const { seasonEnd: _drop, ...rest } = p;
|
||||
p = { ...rest, season: c.season, episodes: { ...c.episodes }, seasonInferred: false };
|
||||
}
|
||||
}
|
||||
return r.size_bytes !== null ? { ...p, sizeBytes: r.size_bytes } : p;
|
||||
}
|
||||
|
||||
/** 单元键:有 content_unit 用其 id,没有时用 s{季}e{集} 合成键(追更判定两侧同口径) */
|
||||
export function unitKeysOf(p: Pick<ParsedRelease, "season" | "episodes" | "airDate">): string[] {
|
||||
if (p.airDate) return [`d${p.airDate}`];
|
||||
if (!p.episodes) return [];
|
||||
const s = p.season ?? 1;
|
||||
const out: string[] = [];
|
||||
for (let e = p.episodes.start; e <= p.episodes.end && out.length < 500; e++) out.push(`s${s}e${e}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 站点字段 → 评估上下文(做种数、促销下载系数、副标题/标签、站点 id、发布后分钟数) */
|
||||
export function contextOf(rel: SourceReleaseRow, now: Date = new Date()): EvaluationContext {
|
||||
const promo = (rel.promotion ?? {}) as { kind?: string; raw?: { downloadFactor?: number | null } };
|
||||
const factor =
|
||||
typeof promo.raw?.downloadFactor === "number"
|
||||
? promo.raw.downloadFactor
|
||||
: promo.kind === "free" || promo.kind === "free2up"
|
||||
? 0
|
||||
: promo.kind === "50pctdown" || promo.kind === "50pctdown2up"
|
||||
? 0.5
|
||||
: promo.kind === "30pctdown"
|
||||
? 0.3
|
||||
: promo.kind === "none" || promo.kind === "2up"
|
||||
? 1
|
||||
: undefined;
|
||||
return {
|
||||
siteId: rel.site_id,
|
||||
...(rel.subtitle ? { subtitle: rel.subtitle } : {}),
|
||||
labels: rel.tags_raw ?? [],
|
||||
...(rel.seeders !== null && rel.seeders !== undefined ? { seeders: rel.seeders } : {}),
|
||||
...(factor !== undefined ? { downloadVolumeFactor: factor } : {}),
|
||||
...(rel.source_published_at
|
||||
? { publishedMinutesAgo: Math.max(0, (now.getTime() - rel.source_published_at.getTime()) / 60_000) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { AcquisitionSlot, SubscriptionParams, SubscriptionProgress, UnitProgressState } from "@umt/contracts";
|
||||
import type { AcquisitionSlotRow, SubscriptionRow } from "@umt/db";
|
||||
import type { AcquisitionSlotRow, SourceReleaseRow, SubscriptionRow } from "@umt/db";
|
||||
import { sql } from "@umt/db";
|
||||
import {
|
||||
currentPri,
|
||||
effectiveLadder,
|
||||
episodeKey,
|
||||
movieCompletion,
|
||||
type ParsedRelease,
|
||||
@@ -10,13 +12,18 @@ import {
|
||||
priorityLadder,
|
||||
type QualityFacts,
|
||||
resolveEpisodeTotal,
|
||||
ruleChainOf,
|
||||
type SeasonProgress,
|
||||
type SlotSpec,
|
||||
seasonProgress,
|
||||
seasonWildcard,
|
||||
type UnitRange,
|
||||
} from "@umt/domain";
|
||||
import { slotSpecFromRow } from "@umt/mapping";
|
||||
import { errMessage, jsonb, toApi } from "../util.ts";
|
||||
import { type AoDeps, type Exec, upsertDecision } from "./common.ts";
|
||||
import { contextOf, parsedOf, unitKeysOf } from "./release-facts.ts";
|
||||
import { entityOriginalLanguage, loadRuleCatalog, mergeHard, type RuleCatalog, resolveRuleChain } from "./rules.ts";
|
||||
|
||||
// W5-C 订阅业务:季总集数账本、缺集/在途、完成判定与历史、Slot 重评调度、分集质量账本、主动搜索调度。
|
||||
// 对照 docs/porting/moviepilot-subscribe.md §3/§7/§8/§9。
|
||||
@@ -39,6 +46,30 @@ export function rangeOf(sub: Pick<SubscriptionRow, "params"> | null | undefined)
|
||||
return { ...(r.start !== undefined ? { start: r.start } : {}), ...(r.end !== undefined ? { end: r.end } : {}) };
|
||||
}
|
||||
|
||||
/** 订阅季号(订阅没有季单元或季单元没有季号时为 null) */
|
||||
export async function subSeasonOf(exec: Exec, sub: Pick<SubscriptionRow, "season_unit_id">): Promise<number | null> {
|
||||
if (!sub.season_unit_id) return null;
|
||||
const su = await exec
|
||||
.selectFrom("content_unit")
|
||||
.select("season_number")
|
||||
.where("id", "=", sub.season_unit_id)
|
||||
.executeTakeFirst();
|
||||
return su?.season_number ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布季号是否在订阅季范围内:仅本季 = 同季;接续后续季 = 不早于订阅季(H4,与覆盖矩阵 coverage.ts 同口径);
|
||||
* 订阅没有季号时不限。
|
||||
*/
|
||||
export function seasonInScope(
|
||||
sub: Pick<SubscriptionRow, "season_scope">,
|
||||
subSeason: number | null,
|
||||
releaseSeason: number,
|
||||
): boolean {
|
||||
if (subSeason === null) return true;
|
||||
return sub.season_scope === "following_seasons" ? releaseSeason >= subSeason : releaseSeason === subSeason;
|
||||
}
|
||||
|
||||
// ── 季总集数账本 ──
|
||||
|
||||
export interface SeasonLedger {
|
||||
@@ -268,15 +299,259 @@ export async function slotInflightPlans(exec: Exec, slotId: string, excludePlanI
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* 同作品的手动下载计划(slot_id 为空)里仍在途的(M6):订阅评估把它们并入在途单元,手动下载中的集不再被订阅重复下。
|
||||
* 手动计划没记单元,按资源解析补:单集 / 多集 → 集键;季包 → 整季通配;电影 → 空(电影收录版本有在途即忙)。
|
||||
*/
|
||||
export async function manualInflightPlans(exec: Exec, entityId: string): Promise<InflightPlan[]> {
|
||||
const rows = await exec
|
||||
.selectFrom("acquisition_plan as p")
|
||||
.innerJoin("source_release as r", "r.id", "p.source_release_id")
|
||||
.leftJoin("download_task as dt", (j) =>
|
||||
j.onRef("dt.acquisition_plan_id", "=", "p.id").on("dt.removed_at", "is", null),
|
||||
)
|
||||
.selectAll("r")
|
||||
.select(["p.id as plan_id", "p.status as plan_status", "p.unit_keys as plan_unit_keys", "dt.status as dt_status"])
|
||||
.where("p.slot_id", "is", null)
|
||||
.where("p.status", "in", [...INFLIGHT_PLAN_STATUSES])
|
||||
.where((eb) =>
|
||||
eb.or([eb("r.media_entity_id", "=", entityId), eb(sql<string>`p.evaluation->>'mediaEntityId'`, "=", entityId)]),
|
||||
)
|
||||
.execute();
|
||||
const byId = new Map<string, InflightPlan>();
|
||||
for (const r of rows) {
|
||||
let keys = r.plan_unit_keys;
|
||||
if (!keys.length) {
|
||||
const parsed = parsedOf(r);
|
||||
keys = unitKeysOf(parsed);
|
||||
if (!keys.length && parsed.season !== undefined) keys = [seasonWildcard(parsed.season)];
|
||||
}
|
||||
const stage: InflightPlan["stage"] =
|
||||
r.plan_status === "reserved"
|
||||
? "reserved"
|
||||
: r.dt_status === "completed" || r.dt_status === "importing"
|
||||
? "verifying"
|
||||
: "downloading";
|
||||
byId.set(r.plan_id, { id: r.plan_id, status: r.plan_status, unitKeys: keys, stage });
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
// ── 完成判定 ──
|
||||
|
||||
export interface SlotCompletion {
|
||||
/** 覆盖齐(Slot 静止态按它落 satisfied / empty) */
|
||||
complete: boolean;
|
||||
/**
|
||||
* 有升级目标(规则组 / 阶梯按 pri 洗版)时,范围内每个已覆盖单元(电影 = 片子)都已到目标 pri;
|
||||
* 没有升级目标恒为 true。订阅完成要求 complete && atTarget(H2)。
|
||||
*/
|
||||
atTarget: boolean;
|
||||
seasons: SeasonProgress[];
|
||||
/** 继续追踪时的疑似缺集(总集数未知,已见最高集以下的空洞) */
|
||||
suspectedMissing: { season: number; episodes: number[] }[];
|
||||
}
|
||||
|
||||
/** 订阅类型 → 规则组 scope 的媒体类型 */
|
||||
const ruleMediaKind = (k: string | null | undefined): "movie" | "series" | "variety" =>
|
||||
k === "movie" ? "movie" : k === "variety" ? "variety" : "series";
|
||||
|
||||
/**
|
||||
* 收录版本的评估口径:行 → SlotSpec,并入规则组链(收录版本 ruleGroupIds → 订阅 ruleGroupIds → 订阅默认链;
|
||||
* 开升级叠加洗版默认链;订阅私有组的包含 / 排除并入硬条件)。siteId 用于规则组的站点范围,不给则不按站点过滤。
|
||||
*/
|
||||
export function slotSpecWithRules(
|
||||
cat: RuleCatalog,
|
||||
slot: AcquisitionSlotRow,
|
||||
sub: Pick<SubscriptionRow, "id" | "params"> | null | undefined,
|
||||
entity: { id: string; kind: string },
|
||||
siteId?: string,
|
||||
): SlotSpec {
|
||||
const spec = slotSpecFromRow(toApi<AcquisitionSlot>(slot));
|
||||
const chain = resolveRuleChain(cat, {
|
||||
kind: "acquisition",
|
||||
slotRuleGroupIds: (slot.rule_group_ids ?? []) as string[],
|
||||
subRuleGroupIds: subParams(sub).ruleGroupIds,
|
||||
upgrade: !!spec.upgrade?.enabled,
|
||||
mediaKind: ruleMediaKind(entity.kind),
|
||||
entityId: entity.id,
|
||||
siteId,
|
||||
subscriptionId: sub?.id,
|
||||
});
|
||||
spec.hard = mergeHard(spec.hard, chain.hard);
|
||||
if (chain.groups.length) spec.rules = { groups: chain.groups, atoms: chain.atoms };
|
||||
return spec;
|
||||
}
|
||||
|
||||
/**
|
||||
* 洗版目标 pri:开了升级且按 pri 口径比较(规则组 / 阶梯有层,或显式 basis=priority)时为 targetPri,缺省 100
|
||||
* (同 evaluateCandidate 的 at_ceiling);按来源等级比较的升级没有「最高档」,返回 null(覆盖齐即完成)。
|
||||
*/
|
||||
export function upgradeTargetPri(spec: SlotSpec): number | null {
|
||||
const up = spec.upgrade;
|
||||
if (!up?.enabled) return null;
|
||||
const tiered = !!spec.soft?.tiers?.length || !!spec.rules?.groups.some((g) => g.layers.length > 0);
|
||||
return (up.basis ?? (tiered ? "priority" : "tier")) === "priority" ? (up.targetPri ?? 100) : null;
|
||||
}
|
||||
|
||||
/** 订阅是否「覆盖齐即完成」:一次性订阅,或仅本季(含电影);接续后续季的持续订阅不完成,等待新季 */
|
||||
export const completesOnCoverage = (sub: Pick<SubscriptionRow, "mode" | "season_scope">): boolean =>
|
||||
sub.mode === "once" || sub.season_scope === "current_season";
|
||||
|
||||
/** 收录版本是否算完成(订阅完成用):覆盖齐且到洗版目标 */
|
||||
export const slotDone = (c: Pick<SlotCompletion, "complete" | "atTarget">): boolean => c.complete && c.atTarget;
|
||||
|
||||
interface PriVersion {
|
||||
/** 覆盖的单元:季 → 集号集合;null = 整季(季包通配) */
|
||||
units: Map<number, Set<number> | null>;
|
||||
pri: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 洗版到顶判定(H2):收集该收录版本的现有版本(资产 + 已接收计划),按当前规则组重算每个版本的 pri
|
||||
* (有来源资源按其标题 / 站点重算,同评估时 rulePriOfAsset;否则取计划评估时记下的 pri;再否则按交付特征算),
|
||||
* 电影任一版本到目标即到顶;剧集要求范围内每个已覆盖单元都有到目标的版本。
|
||||
*/
|
||||
async function upgradeReached(
|
||||
exec: Exec,
|
||||
input: {
|
||||
slot: AcquisitionSlotRow;
|
||||
sub: SubscriptionRow | null;
|
||||
mediaType: "movie" | "tv";
|
||||
target: number;
|
||||
cat: RuleCatalog;
|
||||
newAssets: readonly string[];
|
||||
acceptedPlanIds: readonly string[];
|
||||
entityWide: boolean;
|
||||
/** 剧集:范围内已覆盖的单元(季 → 集号) */
|
||||
covered: Map<number, readonly number[]>;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const { slot, sub, cat } = input;
|
||||
const entity = {
|
||||
id: slot.media_entity_id,
|
||||
kind: sub?.media_kind ?? (input.mediaType === "movie" ? "movie" : "series"),
|
||||
};
|
||||
const lang = await entityOriginalLanguage(exec, slot.media_entity_id);
|
||||
const relPri = (rel: SourceReleaseRow) => {
|
||||
const spec = slotSpecWithRules(cat, slot, sub, entity, rel.site_id);
|
||||
const parsed = parsedOf(rel);
|
||||
const ctx = { ...contextOf(rel), ...(lang ? { originalLanguage: lang } : {}) };
|
||||
return effectiveLadder(spec, parsed, ctx, ruleChainOf(spec, parsed, ctx)).pri;
|
||||
};
|
||||
const baseSpec = slotSpecWithRules(cat, slot, sub, entity);
|
||||
const versions: PriVersion[] = [];
|
||||
const assetIds = [...new Set([...input.newAssets, ...(slot.current_asset_id ? [slot.current_asset_id] : [])])];
|
||||
const assets = await exec
|
||||
.selectFrom("media_asset as a")
|
||||
.leftJoin("source_release as r", "r.id", "a.source_release_id")
|
||||
.leftJoin("download_task as dt", "dt.id", "a.download_task_id")
|
||||
.leftJoin("acquisition_plan as p", "p.id", "dt.acquisition_plan_id")
|
||||
.select(["a.id", "a.media_info", "a.source_release_id", "p.evaluation as plan_evaluation"])
|
||||
.where("a.status", "=", "active")
|
||||
.where("a.parent_asset_id", "is", null)
|
||||
.where((eb) => {
|
||||
const ors = [
|
||||
eb("p.slot_id", "=", slot.id),
|
||||
eb(
|
||||
"a.id",
|
||||
"in",
|
||||
eb
|
||||
.selectFrom("slot_unit")
|
||||
.select("media_asset_id")
|
||||
.where("slot_id", "=", slot.id)
|
||||
.where("media_asset_id", "is not", null)
|
||||
.$castTo<string>(),
|
||||
),
|
||||
];
|
||||
if (assetIds.length) ors.push(eb("a.id", "in", assetIds));
|
||||
if (input.entityWide && input.mediaType === "tv") ors.push(eb("a.media_entity_id", "=", slot.media_entity_id));
|
||||
return eb.or(ors);
|
||||
})
|
||||
.execute();
|
||||
const relIds = [...new Set(assets.map((x) => x.source_release_id).filter((x): x is string => !!x))];
|
||||
const plans = input.acceptedPlanIds.length
|
||||
? await exec
|
||||
.selectFrom("acquisition_plan")
|
||||
.select(["id", "unit_keys", "source_release_id", "evaluation"])
|
||||
.where("id", "in", [...input.acceptedPlanIds])
|
||||
.execute()
|
||||
: [];
|
||||
for (const p of plans) if (p.source_release_id) relIds.push(p.source_release_id);
|
||||
const rels = new Map(
|
||||
(relIds.length
|
||||
? await exec
|
||||
.selectFrom("source_release")
|
||||
.selectAll()
|
||||
.where("id", "in", [...new Set(relIds)])
|
||||
.execute()
|
||||
: []
|
||||
).map((r) => [r.id, r]),
|
||||
);
|
||||
const evalPri = (e: unknown) => {
|
||||
const v = (e as { pri?: unknown } | null)?.pri;
|
||||
return typeof v === "number" ? v : undefined;
|
||||
};
|
||||
const cov = assets.length
|
||||
? await exec
|
||||
.selectFrom("asset_unit_coverage as c")
|
||||
.innerJoin("content_unit as u", "u.id", "c.content_unit_id")
|
||||
.select(["c.media_asset_id", "u.season_number", "u.episode_number"])
|
||||
.where(
|
||||
"c.media_asset_id",
|
||||
"in",
|
||||
assets.map((x) => x.id),
|
||||
)
|
||||
.where("u.kind", "=", "episode")
|
||||
.execute()
|
||||
: [];
|
||||
for (const x of assets) {
|
||||
const rel = x.source_release_id ? rels.get(x.source_release_id) : undefined;
|
||||
const pri =
|
||||
(rel ? relPri(rel) : undefined) ??
|
||||
evalPri(x.plan_evaluation) ??
|
||||
currentPri(
|
||||
baseSpec,
|
||||
((x.media_info as { _umt?: { quality?: QualityFacts } })?._umt?.quality ?? {}) as QualityFacts,
|
||||
);
|
||||
const units = new Map<number, Set<number> | null>();
|
||||
for (const c of cov)
|
||||
if (c.media_asset_id === x.id && c.episode_number !== null) {
|
||||
const s = c.season_number ?? 1;
|
||||
const set = units.get(s) ?? new Set<number>();
|
||||
set.add(c.episode_number);
|
||||
units.set(s, set);
|
||||
}
|
||||
versions.push({ units, pri });
|
||||
}
|
||||
for (const p of plans) {
|
||||
const rel = p.source_release_id ? rels.get(p.source_release_id) : undefined;
|
||||
const pri = (rel ? relPri(rel) : undefined) ?? evalPri(p.evaluation);
|
||||
if (pri === undefined) continue;
|
||||
const units = new Map<number, Set<number> | null>();
|
||||
for (const k of p.unit_keys) {
|
||||
const u = parseUnitKey(k);
|
||||
if (!u) continue;
|
||||
if (u.wildcard) units.set(u.season, null);
|
||||
else if (u.episode !== undefined && units.get(u.season) !== null)
|
||||
units.set(u.season, (units.get(u.season) ?? new Set<number>()).add(u.episode));
|
||||
}
|
||||
versions.push({ units, pri });
|
||||
}
|
||||
if (input.mediaType === "movie") return versions.some((v) => v.pri >= input.target);
|
||||
for (const [season, eps] of input.covered)
|
||||
for (const e of eps) {
|
||||
const best = Math.max(
|
||||
-1,
|
||||
...versions
|
||||
.filter((v) => v.units.has(season) && (v.units.get(season) === null || v.units.get(season)!.has(e)))
|
||||
.map((v) => v.pri),
|
||||
);
|
||||
if (best < input.target) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 剧集 Slot 覆盖的季:Slot 限定季单元 > 订阅季单元 > 作品已知的所有正片季 */
|
||||
async function scopeSeasons(
|
||||
exec: Exec,
|
||||
@@ -314,10 +589,7 @@ async function scopeSeasons(
|
||||
*/
|
||||
export async function slotComplete(
|
||||
exec: Exec,
|
||||
slot: Pick<
|
||||
AcquisitionSlotRow,
|
||||
"id" | "media_entity_id" | "content_unit_id" | "completion" | "subscription_id" | "current_asset_id"
|
||||
>,
|
||||
slot: AcquisitionSlotRow,
|
||||
mediaType: "movie" | "tv",
|
||||
newAssets: readonly string[],
|
||||
opts: { includeSubmitted?: boolean } = {},
|
||||
@@ -333,6 +605,32 @@ export async function slotComplete(
|
||||
.where("p.status_reason", "!=", "accepted_unconfirmed")
|
||||
.execute()
|
||||
: [];
|
||||
const sub = slot.subscription_id
|
||||
? ((await exec.selectFrom("subscription").selectAll().where("id", "=", slot.subscription_id).executeTakeFirst()) ??
|
||||
null)
|
||||
: null;
|
||||
// 洗版目标(H2):有目标时另判是否已到顶;没有目标 atTarget 恒为 true
|
||||
const cat = await loadRuleCatalog(exec);
|
||||
const target = upgradeTargetPri(
|
||||
slotSpecWithRules(cat, slot, sub, {
|
||||
id: slot.media_entity_id,
|
||||
kind: sub?.media_kind ?? (mediaType === "movie" ? "movie" : "series"),
|
||||
}),
|
||||
);
|
||||
const reached = (covered: Map<number, readonly number[]>, entityWide: boolean) =>
|
||||
target === null
|
||||
? Promise.resolve(true)
|
||||
: upgradeReached(exec, {
|
||||
slot,
|
||||
sub,
|
||||
mediaType,
|
||||
target,
|
||||
cat,
|
||||
newAssets,
|
||||
acceptedPlanIds: accepted.map((p) => p.plan_id),
|
||||
entityWide,
|
||||
covered,
|
||||
});
|
||||
if (mediaType === "movie") {
|
||||
const assets = newAssets.length
|
||||
? await exec
|
||||
@@ -355,19 +653,16 @@ export async function slotComplete(
|
||||
: {}),
|
||||
})),
|
||||
]).complete;
|
||||
return { complete, seasons: [], suspectedMissing: [] };
|
||||
return { complete, atTarget: complete && (await reached(new Map(), false)), seasons: [], suspectedMissing: [] };
|
||||
}
|
||||
const sub = slot.subscription_id
|
||||
? ((await exec.selectFrom("subscription").selectAll().where("id", "=", slot.subscription_id).executeTakeFirst()) ??
|
||||
null)
|
||||
: null;
|
||||
const slotCount = await exec
|
||||
.selectFrom("acquisition_slot")
|
||||
.select((eb) => eb.fn.countAll<number>().as("n"))
|
||||
.where("media_entity_id", "=", slot.media_entity_id)
|
||||
.executeTakeFirstOrThrow();
|
||||
// 单 Slot 作品:作品下所有资产都算该 Slot 的覆盖(手动整理/历史导入);多 Slot 只算本 Slot 的
|
||||
const covered = await slotCoveredUnits(exec, slot, { entityWide: Number(slotCount.n) <= 1 });
|
||||
const entityWide = Number(slotCount.n) <= 1;
|
||||
const covered = await slotCoveredUnits(exec, slot, { entityWide });
|
||||
const inflight = await slotInflightPlans(exec, slot.id);
|
||||
const inflightEps = new Map<number, number[]>();
|
||||
for (const p of inflight)
|
||||
@@ -396,8 +691,13 @@ export async function slotComplete(
|
||||
});
|
||||
});
|
||||
const complete = sub?.status !== "pending" && progress.length > 0 && progress.every((p) => p.complete);
|
||||
// 到顶只看范围内(总集数以内)的已覆盖单元
|
||||
const inScope = new Map(
|
||||
progress.map((p) => [p.season, p.total !== null ? p.covered.filter((e) => e <= p.total!) : p.covered] as const),
|
||||
);
|
||||
return {
|
||||
complete,
|
||||
atTarget: complete && (await reached(inScope, entityWide)),
|
||||
seasons: progress,
|
||||
suspectedMissing: progress
|
||||
.filter((p) => p.suspectedMissing.length > 0)
|
||||
@@ -506,14 +806,13 @@ export async function afterSlotVerified(
|
||||
});
|
||||
}
|
||||
|
||||
if (input.completion.complete && slot.subscription_id) {
|
||||
if (slotDone(input.completion) && slot.subscription_id) {
|
||||
const sub = await trx
|
||||
.selectFrom("subscription")
|
||||
.selectAll()
|
||||
.where("id", "=", slot.subscription_id)
|
||||
.executeTakeFirst();
|
||||
const seasonDone = sub?.media_kind !== "movie" && sub?.season_scope === "current_season";
|
||||
if (sub && sub.status === "active" && (sub.mode === "once" || seasonDone)) {
|
||||
if (sub && sub.status === "active" && completesOnCoverage(sub)) {
|
||||
const siblings = await trx
|
||||
.selectFrom("acquisition_slot")
|
||||
.selectAll()
|
||||
@@ -531,7 +830,7 @@ export async function afterSlotVerified(
|
||||
),
|
||||
),
|
||||
);
|
||||
if (others.every((p) => p.complete))
|
||||
if (others.every(slotDone))
|
||||
await completeSubscription(d, trx, sub, "slot_satisfied", { seasons: input.completion.seasons });
|
||||
}
|
||||
}
|
||||
@@ -961,7 +1260,7 @@ export async function afterDownloadAccepted(d: AoDeps, planId: string): Promise<
|
||||
.executeTakeFirst();
|
||||
if (sub?.status !== "active") return;
|
||||
await touchProgress(trx, { subscriptionId: sub.id }, d.ports.now());
|
||||
if (sub.mode !== "once" && sub.season_scope !== "current_season") return;
|
||||
if (!completesOnCoverage(sub)) return;
|
||||
const slots = await trx.selectFrom("acquisition_slot").selectAll().where("subscription_id", "=", sub.id).execute();
|
||||
if (!slots.length) return;
|
||||
const progress = await Promise.all(
|
||||
@@ -975,7 +1274,7 @@ export async function afterDownloadAccepted(d: AoDeps, planId: string): Promise<
|
||||
),
|
||||
),
|
||||
);
|
||||
if (progress.every((p) => p.complete))
|
||||
if (progress.every(slotDone))
|
||||
await completeSubscription(d, trx, sub, "download_accepted", {
|
||||
slots: progress,
|
||||
seasons: progress.flatMap((p) => p.seasons),
|
||||
|
||||
@@ -84,7 +84,8 @@ describe.skipIf(!hasPg)("规则 API(真实 PG)", () => {
|
||||
});
|
||||
expect(ok.body.data).toMatchObject({ revision: 2, softPreferences: { hdr: ["DV"] } });
|
||||
const list = await h.api("GET", `/acquisition-slots?mediaEntityId=${e}`);
|
||||
expect(list.body.data.items).toHaveLength(1);
|
||||
// 新建订阅时已自动建了该订阅自己的「默认版本」
|
||||
expect(list.body.data.items.map((x: { name: string }) => x.name).sort()).toEqual(["1080p", "默认版本"].sort());
|
||||
expect(h.engine.enqueued.some((q) => q.task === "evaluateAcquisition")).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
|
||||
import { submitDownload, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
|
||||
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { applyEpisodeTotal } from "../../src/modules/acquisition-organize/services/subscriptions.ts";
|
||||
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease } from "./harness.ts";
|
||||
|
||||
// 洗版完成口径(H2):收录版本有升级目标(规则组阶梯按 pri 洗版)时,覆盖齐还不够,要到最高档才完成订阅。
|
||||
// 电影洗版、按集洗版、全集洗版各一例;下载器接收即计入(afterDownloadAccepted)与入库后(afterSlotVerified)同口径。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const SIZE = 300_000;
|
||||
|
||||
describe.skipIf(!hasPg)("洗版到最高档才完成(真实 PG + mock qB + 临时目录)", () => {
|
||||
let h: Harness;
|
||||
let qb: MockQb;
|
||||
let lib: TempTree;
|
||||
let cache: TempTree;
|
||||
const disk = fakeDisk(10_000_000_000);
|
||||
const torrents = new Map<string, Uint8Array>();
|
||||
let base: Awaited<ReturnType<typeof seedBase>>;
|
||||
let groupId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
qb = await createMockQb({ addVisibleAfterPolls: 0 });
|
||||
lib = await createTempTree({}, { sentinel: true, prefix: "umt-lib-" });
|
||||
cache = await createTempTree({}, { prefix: "umt-cache-" });
|
||||
disk.state.watch = [cache.root];
|
||||
disk.setPct(10);
|
||||
h = await createHarness("upgradedone", {
|
||||
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 });
|
||||
const g = await h.api("POST", "/rules", { name: "洗版到 REMUX", ruleString: "REMUX > WEBDL" });
|
||||
expect(g.status, JSON.stringify(g.body)).toBe(201);
|
||||
groupId = g.body.data.id;
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await qb?.close();
|
||||
await lib?.cleanup();
|
||||
await cache?.cleanup();
|
||||
});
|
||||
|
||||
const db = () => h.db;
|
||||
|
||||
async function washSub(kind: "movie" | "series", title: string, mode: "movie" | "per_unit" | "full_pack") {
|
||||
const entityId = await seedEntity(db(), { kind, title, year: 2024 });
|
||||
const sub = await db()
|
||||
.insertInto("subscription")
|
||||
.values({ media_entity_id: entityId, media_kind: kind, mode: "once", season_scope: "current_season" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await db()
|
||||
.insertInto("acquisition_slot")
|
||||
.values({
|
||||
media_entity_id: entityId,
|
||||
subscription_id: sub.id,
|
||||
name: "洗版",
|
||||
upgrade_threshold: JSON.stringify({ enabled: true, mode }),
|
||||
rule_group_ids: JSON.stringify([groupId]),
|
||||
})
|
||||
.execute();
|
||||
return { entityId, subId: sub.id };
|
||||
}
|
||||
|
||||
async function release(entityId: string, title: string, files?: { path: string; length: number }[]) {
|
||||
const t = files
|
||||
? makeTorrent({ name: title, files, pieceSeed: Math.floor(Math.random() * 1e6) })
|
||||
: 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, hash: t.hash };
|
||||
}
|
||||
const subRow = (id: string) =>
|
||||
db().selectFrom("subscription").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||
const planRow = (id: string) =>
|
||||
db().selectFrom("acquisition_plan").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||
|
||||
async function planFor(relId: string) {
|
||||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: relId });
|
||||
expect(ev.plans, JSON.stringify(ev)).toHaveLength(1);
|
||||
return ev.plans[0]!;
|
||||
}
|
||||
async function submit(planId: string) {
|
||||
const s = await submitDownload(h.d, planId);
|
||||
expect(s.status).toBe("submitted");
|
||||
return s.downloadTaskId!;
|
||||
}
|
||||
async function completeAndImport(hash: string, dtId: string) {
|
||||
await qb.complete(hash, { writeFiles: true });
|
||||
const sync = await syncDownloads(h.d);
|
||||
expect(sync.completed).toContain(dtId);
|
||||
const p = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" });
|
||||
expect(p.status, JSON.stringify(p)).toBe("running");
|
||||
const ex = await executeRecord(h.d, p.recordId!);
|
||||
expect(ex.status).toBe("succeeded");
|
||||
}
|
||||
|
||||
it("电影洗版:WEB-DL(pri 99)接收、入库都不完成;REMUX(pri 100)接收即完成", async () => {
|
||||
const { entityId, subId } = await washSub("movie", "Wash Film", "movie");
|
||||
const web = await release(entityId, "Wash.Film.2024.1080p.WEB-DL.x264-GRP");
|
||||
const webTask = await submit(await planFor(web.id));
|
||||
expect(await subRow(subId)).toMatchObject({ status: "active" });
|
||||
await completeAndImport(web.hash, webTask);
|
||||
expect(await subRow(subId)).toMatchObject({ status: "active" });
|
||||
const remux = await release(entityId, "Wash.Film.2024.1080p.BluRay.REMUX.AVC-GRP");
|
||||
const up = await planFor(remux.id);
|
||||
expect(await planRow(up)).toMatchObject({ purpose: "upgrade" });
|
||||
await submit(up);
|
||||
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
|
||||
}, 60_000);
|
||||
|
||||
it("按集洗版:全集已覆盖但不是最高档时不完成;逐集洗到 REMUX 后完成", async () => {
|
||||
const { entityId, subId } = await washSub("series", "Wash Episodes", "per_unit");
|
||||
await db()
|
||||
.transaction()
|
||||
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 1, now: new Date() }));
|
||||
const web = await release(entityId, "Wash.Episodes.S01E01.1080p.WEB-DL.x264-GRP");
|
||||
const webTask = await submit(await planFor(web.id));
|
||||
expect(await subRow(subId)).toMatchObject({ status: "active" });
|
||||
await completeAndImport(web.hash, webTask);
|
||||
expect(await subRow(subId)).toMatchObject({ status: "active" });
|
||||
const remux = await release(entityId, "Wash.Episodes.S01E01.1080p.BluRay.REMUX.AVC-GRP");
|
||||
const up = await planFor(remux.id);
|
||||
expect(await planRow(up)).toMatchObject({ purpose: "upgrade", unit_keys: ["s1e1"] });
|
||||
await submit(up);
|
||||
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
|
||||
}, 60_000);
|
||||
|
||||
it("全集洗版:WEB-DL 季包覆盖全季不完成;单集 REMUX 不接;REMUX 季包后完成", async () => {
|
||||
const { entityId, subId } = await washSub("series", "Wash Pack", "full_pack");
|
||||
await db()
|
||||
.transaction()
|
||||
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 2, now: new Date() }));
|
||||
const files = (tag: string) => [1, 2].map((e) => ({ path: `Wash.Pack.S01E0${e}.1080p.${tag}.mkv`, length: SIZE }));
|
||||
const web = await release(entityId, "Wash.Pack.S01.1080p.WEB-DL.x264-GRP", files("WEB-DL.x264-GRP"));
|
||||
const webPlan = await planFor(web.id);
|
||||
expect((await planRow(webPlan)).unit_keys).toEqual(["s1e1", "s1e2"]);
|
||||
const webTask = await submit(webPlan);
|
||||
expect(await subRow(subId)).toMatchObject({ status: "active" });
|
||||
await completeAndImport(web.hash, webTask);
|
||||
expect(await subRow(subId)).toMatchObject({ status: "active" });
|
||||
const single = await release(entityId, "Wash.Pack.S01E01.1080p.BluRay.REMUX.AVC-GRP");
|
||||
expect((await evaluateAcquisition(h.d, { sourceReleaseId: single.id })).plans).toHaveLength(0);
|
||||
const remux = await release(entityId, "Wash.Pack.S01.1080p.BluRay.REMUX.AVC-GRP", files("BluRay.REMUX.AVC-GRP"));
|
||||
const up = await planFor(remux.id);
|
||||
expect(await planRow(up)).toMatchObject({ purpose: "upgrade" });
|
||||
await submit(up);
|
||||
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
|
||||
import { ensureSeasonUnit } from "../../src/modules/acquisition-organize/services/subscriptions.ts";
|
||||
import {
|
||||
createHarness,
|
||||
fakeDisk,
|
||||
type Harness,
|
||||
pgAvailable,
|
||||
seedBase,
|
||||
seedEntity,
|
||||
seedRelease,
|
||||
} from "../acquisition-organize/harness.ts";
|
||||
|
||||
// 获取正确性(T3):订阅总有自己的收录版本(H1)、接续后续季的季下界(H4)、重评择优(M4)、
|
||||
// 重评不止最近 50 条(M5)、手动下载中的集计入在途(M6)。只到建计划为止(假引擎,不提交下载器)。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const SIZE = 300_000;
|
||||
|
||||
describe.skipIf(!hasPg)("订阅范围与重评(真实 PG)", () => {
|
||||
let h: Harness;
|
||||
const disk = fakeDisk(10_000_000_000);
|
||||
let base: Awaited<ReturnType<typeof seedBase>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
disk.setPct(10);
|
||||
h = await createHarness("subscope", { diskUsage: disk.fn });
|
||||
base = await seedBase(h.db, {
|
||||
libraryPath: "/tmp/nonexistent-lib",
|
||||
cachePath: "/tmp",
|
||||
qbUrl: "http://127.0.0.1:9",
|
||||
});
|
||||
// 计划不提交,预留会一直占并发名额:放宽并发上限
|
||||
await h.setSettings("budget", { maxConcurrentDownloads: 100 });
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
const db = () => h.db;
|
||||
let seq = 0;
|
||||
const hash = () => (++seq).toString(16).padStart(40, "a");
|
||||
async function release(entityId: string, title: string, firstSeen?: Date) {
|
||||
const id = await seedRelease(db(), { siteId: base.siteId, entityId, title, size: SIZE, hash: hash() });
|
||||
if (firstSeen)
|
||||
await db().updateTable("source_release").set({ first_seen_at: firstSeen }).where("id", "=", id).execute();
|
||||
return id;
|
||||
}
|
||||
const slotsOf = (subId: string) =>
|
||||
db().selectFrom("acquisition_slot").selectAll().where("subscription_id", "=", subId).orderBy("position").execute();
|
||||
const planRow = (id: string) =>
|
||||
db().selectFrom("acquisition_plan").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||
async function seasons(entityId: string, n: number) {
|
||||
const ids: string[] = [];
|
||||
for (let s = 1; s <= n; s++)
|
||||
ids.push(
|
||||
await db()
|
||||
.transaction()
|
||||
.execute((trx) => ensureSeasonUnit(trx, entityId, s)),
|
||||
);
|
||||
return ids;
|
||||
}
|
||||
async function createSub(body: Record<string, unknown>) {
|
||||
const r = await h.api("POST", "/subscriptions", body);
|
||||
expect(r.status, JSON.stringify(r.body)).toBe(201);
|
||||
return r.body.data.id as string;
|
||||
}
|
||||
|
||||
it("H1:无策略模板、没填质量预设的订阅也有自己的默认收录版本,能下载", async () => {
|
||||
const e = await seedEntity(db(), { kind: "series", title: "无预设剧", year: 2025 });
|
||||
const subId = await createSub({ mediaEntityId: e, mediaKind: "series" });
|
||||
const slots = await slotsOf(subId);
|
||||
expect(slots).toHaveLength(1);
|
||||
expect(slots[0]).toMatchObject({ name: "默认版本", hard_conditions: {} });
|
||||
const r = await release(e, "No.Preset.S01E01.1080p.WEB-DL.x264-GRP");
|
||||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: r });
|
||||
expect(ev.plans, JSON.stringify(ev)).toHaveLength(1);
|
||||
expect((await planRow(ev.plans[0]!)).slot_id).toBe(slots[0]!.id);
|
||||
});
|
||||
|
||||
it("H1:S1 旧订阅已有收录版本时再订 S2,S2 订阅建自己的收录版本(重名加序号)并能下载", async () => {
|
||||
const e = await seedEntity(db(), { kind: "series", title: "两季剧", year: 2024 });
|
||||
const [s1, s2] = await seasons(e, 2);
|
||||
const sub1 = await createSub({
|
||||
mediaEntityId: e,
|
||||
mediaKind: "series",
|
||||
seasonUnitId: s1,
|
||||
seasonScope: "current_season",
|
||||
params: { resolution: "1080p" },
|
||||
});
|
||||
expect(await slotsOf(sub1)).toHaveLength(1);
|
||||
await db().updateTable("subscription").set({ status: "completed" }).where("id", "=", sub1).execute();
|
||||
const sub2 = await createSub({
|
||||
mediaEntityId: e,
|
||||
mediaKind: "series",
|
||||
seasonUnitId: s2,
|
||||
seasonScope: "current_season",
|
||||
params: { resolution: "1080p" },
|
||||
});
|
||||
const own = await slotsOf(sub2);
|
||||
expect(own).toHaveLength(1);
|
||||
expect(own[0]!.name).toBe("默认版本 2");
|
||||
// 质量预设写进了 S2 订阅自己的收录版本
|
||||
expect(own[0]!.hard_conditions).toMatchObject({ resolutions: ["1080p", "1080i"] });
|
||||
const r = await release(e, "Two.Seasons.S02E01.1080p.WEB-DL.x264-GRP");
|
||||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: r });
|
||||
expect(ev.plans, JSON.stringify(ev)).toHaveLength(1);
|
||||
expect((await planRow(ev.plans[0]!)).slot_id).toBe(own[0]!.id);
|
||||
});
|
||||
|
||||
it("H1:已有的 0 收录版本订阅首次评估时补建;编辑时也补建", async () => {
|
||||
const e = await seedEntity(db(), { kind: "movie", title: "Legacy Film", year: 2023 });
|
||||
const sub = await db()
|
||||
.insertInto("subscription")
|
||||
.values({ media_entity_id: e, media_kind: "movie", mode: "once", season_scope: "current_season" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(await slotsOf(sub.id)).toHaveLength(0);
|
||||
const r = await release(e, "Legacy.Film.2023.1080p.WEB-DL.x264-GRP");
|
||||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: r });
|
||||
expect(ev.plans, JSON.stringify(ev)).toHaveLength(1);
|
||||
expect(await slotsOf(sub.id)).toHaveLength(1);
|
||||
|
||||
const e2 = await seedEntity(db(), { kind: "movie", title: "Legacy Film 2", year: 2023 });
|
||||
const sub2 = await db()
|
||||
.insertInto("subscription")
|
||||
.values({ media_entity_id: e2, media_kind: "movie", mode: "once", season_scope: "current_season" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
const p = await h.api("PATCH", `/subscriptions/${sub2.id}`, { params: { resolution: "2160p" } });
|
||||
expect(p.status, JSON.stringify(p.body)).toBe(200);
|
||||
const [slot] = await slotsOf(sub2.id);
|
||||
expect(slot?.hard_conditions).toMatchObject({ resolutions: ["2160p"] });
|
||||
});
|
||||
|
||||
it("H4:从 S3 订「接续后续季」不下 S1 / S2,S3 及以后照常", async () => {
|
||||
const e = await seedEntity(db(), { kind: "series", title: "三季剧", year: 2020 });
|
||||
const [, , s3] = await seasons(e, 4);
|
||||
const subId = await createSub({
|
||||
mediaEntityId: e,
|
||||
mediaKind: "series",
|
||||
seasonUnitId: s3,
|
||||
seasonScope: "following_seasons",
|
||||
params: { resolution: "1080p" },
|
||||
});
|
||||
const planned = async (title: string) =>
|
||||
(await evaluateAcquisition(h.d, { sourceReleaseId: await release(e, title) })).plans.length;
|
||||
expect(await planned("Three.Seasons.S01E01.1080p.WEB-DL.x264-GRP")).toBe(0);
|
||||
expect(await planned("Three.Seasons.S02E05.1080p.WEB-DL.x264-GRP")).toBe(0);
|
||||
expect(await planned("Three.Seasons.S02.1080p.WEB-DL.x264-GRP")).toBe(0);
|
||||
expect(await planned("Three.Seasons.S03E01.1080p.WEB-DL.x264-GRP")).toBe(1);
|
||||
expect(await planned("Three.Seasons.S04E01.1080p.WEB-DL.x264-GRP")).toBe(1);
|
||||
// 订阅重评同口径
|
||||
const re = await evaluateAcquisition(h.d, { subscriptionId: subId });
|
||||
expect(re.plans).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("M4:订阅重评时同一集多个合格资源选 pri 最高的,不是最新的", async () => {
|
||||
const e = await seedEntity(db(), { kind: "series", title: "择优剧", year: 2024 });
|
||||
const g = await h.api("POST", "/rules", { name: "择优阶梯", ruleString: "REMUX > WEBDL" });
|
||||
expect(g.status, JSON.stringify(g.body)).toBe(201);
|
||||
const t0 = Date.now();
|
||||
const remux = await release(e, "Pick.Best.S01E01.1080p.BluRay.REMUX.AVC-GRP", new Date(t0 - 3_600_000));
|
||||
await release(e, "Pick.Best.S01E01.1080p.WEB-DL.x264-NEW", new Date(t0 - 60_000));
|
||||
// 订阅建好后 kickEvaluate 只入队(假引擎),这里直接跑订阅重评
|
||||
const subId = await createSub({
|
||||
mediaEntityId: e,
|
||||
mediaKind: "series",
|
||||
params: { filter_groups: [g.body.data.id], resolution: "1080p" },
|
||||
});
|
||||
const ev = await evaluateAcquisition(h.d, { subscriptionId: subId });
|
||||
expect(ev.plans, JSON.stringify(ev)).toHaveLength(1);
|
||||
expect(await planRow(ev.plans[0]!)).toMatchObject({ source_release_id: remux, unit_keys: ["s1e1"] });
|
||||
expect(ev.deferred.map((x) => x.reason)).toEqual(["units_inflight"]);
|
||||
});
|
||||
|
||||
it("M5:超过 50 条较新的资源之后,更早的集仍能被重评选中", async () => {
|
||||
const e = await seedEntity(db(), { kind: "series", title: "长剧", year: 2018 });
|
||||
const subId = await createSub({ mediaEntityId: e, mediaKind: "series", params: { resolution: "1080p" } });
|
||||
const t0 = Date.now();
|
||||
const old = await release(e, "Long.Show.S01E01.1080p.WEB-DL.x264-GRP", new Date(t0 - 86_400_000));
|
||||
// 55 条更新的 720p 资源(不合格)把最近 50 条占满
|
||||
for (let i = 2; i <= 56; i++)
|
||||
await release(e, `Long.Show.S01E${String(i).padStart(2, "0")}.720p.WEB-DL.x264-GRP`, new Date(t0 - i * 1000));
|
||||
const ev = await evaluateAcquisition(h.d, { subscriptionId: subId });
|
||||
expect(ev.plans, JSON.stringify(ev.skipped.slice(0, 3))).toHaveLength(1);
|
||||
expect(await planRow(ev.plans[0]!)).toMatchObject({ source_release_id: old, unit_keys: ["s1e1"] });
|
||||
}, 60_000);
|
||||
|
||||
it("M6:手动下载中的集,订阅不再用别的资源重复下;没在手动下载的集照常", async () => {
|
||||
const e = await seedEntity(db(), { kind: "series", title: "手动剧", year: 2024 });
|
||||
const subId = await createSub({ mediaEntityId: e, mediaKind: "series", params: { resolution: "1080p" } });
|
||||
const [slot] = await slotsOf(subId);
|
||||
const manualRel = await release(e, "Manual.Show.S01E01.1080p.WEB-DL.x264-USER");
|
||||
await db()
|
||||
.insertInto("acquisition_plan")
|
||||
.values({
|
||||
slot_id: null,
|
||||
source_release_id: manualRel,
|
||||
status: "submitted",
|
||||
priority: "immediate",
|
||||
evaluation: JSON.stringify({ manual: true, mediaEntityId: e }),
|
||||
payload_hash: "manual-test",
|
||||
idempotency_key: `manual:test:${manualRel}`,
|
||||
status_reason: "manual_selection",
|
||||
})
|
||||
.execute();
|
||||
const other = await release(e, "Manual.Show.S01E01.1080p.WEB-DL.x265-OTHER");
|
||||
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: other });
|
||||
expect(ev.plans).toHaveLength(0);
|
||||
expect(ev.deferred).toEqual([{ sourceReleaseId: other, slotId: slot!.id, reason: "units_inflight" }]);
|
||||
// 手动计划结束不会触发重评:延后时排了退避重评
|
||||
const s = await db()
|
||||
.selectFrom("acquisition_slot")
|
||||
.select("next_evaluate_at")
|
||||
.where("id", "=", slot!.id)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(s.next_evaluate_at).not.toBeNull();
|
||||
const e2 = await release(e, "Manual.Show.S01E02.1080p.WEB-DL.x264-OTHER");
|
||||
expect((await evaluateAcquisition(h.d, { sourceReleaseId: e2 })).plans).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -73,7 +73,11 @@ export function ProgressBlock({ x, className }: { x: Pick<Item, "progress" | "mo
|
||||
);
|
||||
}
|
||||
|
||||
export function SlotBadges({ slots, max = 4 }: { slots: Item["slots"]; max?: number }) {
|
||||
/** 只按标题订阅、没关联媒体库作品时的提示:这类订阅不搜索也不下载(L5) */
|
||||
export const UNBOUND_HINT = "未关联作品,不会自动搜索和下载";
|
||||
|
||||
export function SlotBadges({ slots, max = 4, unbound }: { slots: Item["slots"]; max?: number; unbound?: boolean }) {
|
||||
if (unbound) return <span className="text-meta text-warning">{UNBOUND_HINT}</span>;
|
||||
if (slots.length === 0) return <span className="text-meta text-muted-foreground">没有收录版本</span>;
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap gap-1">
|
||||
@@ -181,7 +185,7 @@ export function SubscriptionCard({
|
||||
</div>
|
||||
</div>
|
||||
<ProgressBlock x={x} />
|
||||
<SlotBadges slots={x.slots} />
|
||||
<SlotBadges slots={x.slots} unbound={!x.mediaEntityId} />
|
||||
<Meta x={x} />
|
||||
<p className="text-meta text-muted-foreground">{autoFillText(x.autoFill)}</p>
|
||||
</article>
|
||||
@@ -227,7 +231,7 @@ export function SubscriptionRow({
|
||||
</Link>
|
||||
<ProgressBlock x={x} className="hidden md:block" />
|
||||
<div className="relative hidden min-w-0 md:block">
|
||||
<SlotBadges slots={x.slots} max={3} />
|
||||
<SlotBadges slots={x.slots} max={3} unbound={!x.mediaEntityId} />
|
||||
</div>
|
||||
<span className="hidden text-meta text-muted-foreground tabular-nums md:block">{relTime(x.lastUpdatedAt)}</span>
|
||||
<span className="hidden text-meta text-muted-foreground tabular-nums md:block">{nextSearchText(x)}</span>
|
||||
|
||||
@@ -830,7 +830,7 @@ export function SubscriptionFormDialog({
|
||||
slotsQ.isPending ? (
|
||||
<p className="text-caption text-muted-foreground">读取收录版本…</p>
|
||||
) : subSlots.length === 0 ? (
|
||||
<p className="text-caption text-muted-foreground">还没有收录版本。在对比视图里新建。</p>
|
||||
<p className="text-caption text-muted-foreground">还没有收录版本,保存时自动建一个默认版本。</p>
|
||||
) : (
|
||||
<FormField label="顺序" htmlFor={f("order")} hint="第一个是主版本;其他版本按自动补全设置补齐">
|
||||
<SlotOrderList
|
||||
@@ -846,7 +846,7 @@ export function SubscriptionFormDialog({
|
||||
htmlFor={f("tpl")}
|
||||
hint={
|
||||
target?.kind === "entity"
|
||||
? "作品还没有收录版本时按模板顺序创建,第一个是主版本"
|
||||
? "按模板顺序给本订阅创建收录版本,第一个是主版本;没有模板时建一个默认版本"
|
||||
: "作品加入媒体库后按模板创建收录版本"
|
||||
}
|
||||
>
|
||||
|
||||
@@ -121,10 +121,16 @@ export default function SubscriptionDetailPage() {
|
||||
{item?.awaitingNewSeason ? <StatusBadge tone="success">等待新季</StatusBadge> : null}
|
||||
{!sub.mediaEntityId ? <StatusBadge tone="warning">不在媒体库</StatusBadge> : null}
|
||||
</div>
|
||||
{!sub.mediaEntityId ? (
|
||||
<p className="max-w-xl rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||
只按标题「{sub.titleQuery ?? title}
|
||||
」订阅,还没关联到媒体库作品,不会自动搜索和下载。先把作品加入媒体库,再从作品订阅。
|
||||
</p>
|
||||
) : null}
|
||||
{item ? (
|
||||
<div className="grid max-w-xl gap-2">
|
||||
<ProgressBlock x={item} />
|
||||
<SlotBadges slots={item.slots} max={6} />
|
||||
<SlotBadges slots={item.slots} max={6} unbound={!sub.mediaEntityId} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user