diff --git a/apps/server/src/modules/acquisition-organize/services/acquisition.ts b/apps/server/src/modules/acquisition-organize/services/acquisition.ts index ce13f61c..70e216d5 100644 --- a/apps/server/src/modules/acquisition-organize/services/acquisition.ts +++ b/apps/server/src/modules/acquisition-organize/services/acquisition.ts @@ -12,6 +12,7 @@ import { checkBudget, computeUnitGaps, determineMode, + effectiveLadder, type EvaluationContext, episodeKey, evaluateCandidate, @@ -20,7 +21,9 @@ import { parseRelease, priorityOf, type QualityFacts, + ruleChainOf, type SlotEvaluation, + type SlotSpec, type SlotState, seasonWildcard, transition, @@ -228,6 +231,8 @@ interface Ctx { rules?: Promise; /** 作品原语言缓存(规则组 CNSUB/CNVOI 放行) */ langs?: Map>; + /** 现有资产在规则组下的 pri 缓存 */ + assetPri?: Map; } async function note(c: Ctx, base: Omit) { @@ -574,6 +579,10 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string | if (isMovie) { if (inflight.length > 0 || BUSY.includes(slot.status)) return defer("slot_busy", []); const current = await currentFacts(db, slot.current_asset_id); + if (current && slot.current_asset_id && rulesLaddered(spec)) { + const pri = await rulePriOfAsset(c, spec, slot.current_asset_id, ctx.originalLanguage); + if (pri !== undefined) current.pri = pri; + } ev = evaluateCandidate(spec, parsed, slotSatisfied ? current : null, ctx); } else { const cov = await slotCoveredUnits(db, slot, { entityWide: p.slotCount <= 1 }); @@ -623,7 +632,12 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string | return defer("units_inflight", reservedHit); } else { // 都已覆盖:分集洗版,只取能提升的单元(对照 MP get_best_version_interested_episodes) - const unitCurrent = await unitQuality(db, slot, releaseKeys); + const unitCurrent = await unitQuality( + db, + slot, + releaseKeys, + rulesLaddered(spec) ? (assetId) => rulePriOfAsset(c, spec, assetId, ctx.originalLanguage) : undefined, + ); const withCurrent = releaseKeys.filter((k) => unitCurrent[k]); const firstCurrent = withCurrent.length ? unitCurrent[withCurrent[0]!]! @@ -743,11 +757,13 @@ async function unitQuality( exec: Exec, slot: AcquisitionSlotRow, keys: readonly string[], + /** 版本目标引用了带层的规则组时,按规则组重算该单元现有资产的 pri(覆盖入库时按 soft.tiers 记下的值) */ + priOf?: (assetId: string) => Promise, ): Promise> { const rows = await exec .selectFrom("slot_unit as su") .innerJoin("content_unit as u", "u.id", "su.content_unit_id") - .select(["u.season_number", "u.episode_number", "su.quality", "su.pri"]) + .select(["u.season_number", "u.episode_number", "su.quality", "su.pri", "su.media_asset_id"]) .where("su.slot_id", "=", slot.id) .where("su.media_asset_id", "is not", null) .execute(); @@ -757,11 +773,50 @@ async function unitQuality( if (r.episode_number === null) continue; const k = episodeKey(r.season_number ?? 1, r.episode_number); if (!want.has(k)) continue; - out[k] = { ...((r.quality ?? {}) as QualityFacts), ...(r.pri !== null ? { pri: r.pri } : {}) }; + const pri = priOf && r.media_asset_id ? ((await priOf(r.media_asset_id)) ?? r.pri) : r.pri; + out[k] = { ...((r.quality ?? {}) as QualityFacts), ...(pri !== null ? { pri } : {}) }; } return out; } +const rulesLaddered = (spec: SlotSpec) => !!spec.rules?.groups.some((g) => g.layers.length > 0); + +/** + * 现有资产在规则组下的 pri:有来源资源 → 按当前规则组对其标题/副标题/站点事实重算(同 MP 对种子求 pri_order); + * 否则取下载计划评估时记下的 pri。一次评估内按资产缓存。 + */ +async function rulePriOfAsset(c: Ctx, spec: SlotSpec, assetId: string, lang?: string): Promise { + c.assetPri ??= new Map(); + const key = `${spec.id ?? ""}:${assetId}`; + if (c.assetPri.has(key)) return c.assetPri.get(key); + const db = c.d.ctx.db; + let pri: number | undefined; + const a = await db + .selectFrom("media_asset") + .select(["source_release_id", "download_task_id"]) + .where("id", "=", assetId) + .executeTakeFirst(); + const rel = a?.source_release_id + ? await db.selectFrom("source_release").selectAll().where("id", "=", a.source_release_id).executeTakeFirst() + : undefined; + if (rel) { + const parsed = parsedOf(rel); + const ctx = { ...contextOf(rel, c.now), ...(lang ? { originalLanguage: lang } : {}) }; + pri = effectiveLadder(spec, parsed, ctx, ruleChainOf(spec, parsed, ctx)).pri; + } else if (a?.download_task_id) { + const p = await db + .selectFrom("download_task as t") + .innerJoin("acquisition_plan as p", "p.id", "t.acquisition_plan_id") + .select("p.evaluation") + .where("t.id", "=", a.download_task_id) + .executeTakeFirst(); + const v = (p?.evaluation as { pri?: unknown } | undefined)?.pri; + if (typeof v === "number") pri = v; + } + c.assetPri.set(key, pri); + return pri; +} + interface GateOk { ok: true; instanceId: string; diff --git a/apps/server/test/rules/rule-groups.test.ts b/apps/server/test/rules/rule-groups.test.ts index c8e96d66..bbd8aeed 100644 --- a/apps/server/test/rules/rule-groups.test.ts +++ b/apps/server/test/rules/rule-groups.test.ts @@ -225,6 +225,66 @@ describe.skipIf(!hasPg)("规则组 API(真实 PG)", () => { expect((await h.api("DELETE", `/rules/${g.body.data.id}`)).status).toBe(409); }); + it("洗版:现有资产按规则组重算 pri,只接受 pri 严格更高的候选", async () => { + const e = await seedEntity(h.db, { kind: "movie", title: "洗版片", year: 2024 }); + const slot = await seedSlot(h.db, { entityId: e, upgrade: { enabled: true } }); + const g = await h.api("POST", "/rules", { name: "洗版阶梯", ruleString: "REMUX > WEBDL & CNSUB > WEBDL" }); + await h.api("PATCH", `/acquisition-slots/${slot}`, { expectedRevision: 1, ruleGroupIds: [g.body.data.id] }); + const tOld = makeTorrent({ name: "Up.2024.1080p.WEB-DL.mkv", length: 1000, pieceSeed: 61 }); + const oldRel = await seedRelease(h.db, { + siteId: base.siteId, + entityId: e, + title: "Up.2024.1080p.WEB-DL.H264-OLD", + size: 1000, + hash: tOld.hash, + }); + await h.db.updateTable("source_release").set({ subtitle: "中字" }).where("id", "=", oldRel).execute(); + const asset = await h.db + .insertInto("media_asset") + .values({ + media_entity_id: e, + kind: "video", + size_bytes: 1000, + source_release_id: oldRel, + media_info: JSON.stringify({ _umt: { quality: { resolution: "1080p", source: "WEB-DL" } } }), + }) + .returning("id") + .executeTakeFirstOrThrow(); + await h.db + .updateTable("acquisition_slot") + .set({ status: "satisfied", current_asset_id: asset.id }) + .where("id", "=", slot) + .execute(); + // 现有资产 = WEB-DL + 中字 → pri 99;同档 WEB-DL 中字不换,REMUX(pri 100)升级 + const tSame = makeTorrent({ name: "Up.2024.1080p.WEB-DL.2.mkv", length: 1000, pieceSeed: 62 }); + const same = await seedRelease(h.db, { + siteId: base.siteId, + entityId: e, + title: "Up.2024.1080p.WEB-DL.H264-NEW", + size: 1000, + hash: tSame.hash, + }); + await h.db.updateTable("source_release").set({ subtitle: "中字" }).where("id", "=", same).execute(); + expect((await evaluateAcquisition(h.d, { sourceReleaseId: same })).plans).toHaveLength(0); + const tBetter = makeTorrent({ name: "Up.2024.1080p.REMUX.mkv", length: 1000, pieceSeed: 63 }); + const better = await seedRelease(h.db, { + siteId: base.siteId, + entityId: e, + title: "Up.2024.1080p.BluRay.REMUX.AVC-GRP", + size: 1000, + hash: tBetter.hash, + }); + const ev = await evaluateAcquisition(h.d, { sourceReleaseId: better }); + expect(ev.plans).toHaveLength(1); + const plan = await h.db + .selectFrom("acquisition_plan") + .select(["purpose", "evaluation"]) + .where("id", "=", ev.plans[0]!) + .executeTakeFirstOrThrow(); + expect(plan.purpose).toBe("upgrade"); + expect(plan.evaluation).toMatchObject({ pri: 100 }); + }); + it("默认链:订阅默认组有层时对未引用规则组的版本目标生效;PUT /rules/defaults 校验类型", async () => { const defs = (await h.api("GET", "/rules/defaults")).body.data; const dflt = defs.acquisition[0];