fix(rules): recompute current asset pri under referenced rule groups for upgrades
Slot units and movie assets recorded pri from slot soft.tiers only, so a rule group ladder would either never upgrade (pri 100) or re-upgrade forever. The current pri is now re-evaluated from the asset's source release with the same chain (fallback: pri recorded in the plan evaluation). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
d28686ed02
commit
36027a4820
2 files changed
+118
-3
No files matched your search
@@ -12,6 +12,7 @@ import {
|
|||||||
checkBudget,
|
checkBudget,
|
||||||
computeUnitGaps,
|
computeUnitGaps,
|
||||||
determineMode,
|
determineMode,
|
||||||
|
effectiveLadder,
|
||||||
type EvaluationContext,
|
type EvaluationContext,
|
||||||
episodeKey,
|
episodeKey,
|
||||||
evaluateCandidate,
|
evaluateCandidate,
|
||||||
@@ -20,7 +21,9 @@ import {
|
|||||||
parseRelease,
|
parseRelease,
|
||||||
priorityOf,
|
priorityOf,
|
||||||
type QualityFacts,
|
type QualityFacts,
|
||||||
|
ruleChainOf,
|
||||||
type SlotEvaluation,
|
type SlotEvaluation,
|
||||||
|
type SlotSpec,
|
||||||
type SlotState,
|
type SlotState,
|
||||||
seasonWildcard,
|
seasonWildcard,
|
||||||
transition,
|
transition,
|
||||||
@@ -228,6 +231,8 @@ interface Ctx {
|
|||||||
rules?: Promise<RuleCatalog>;
|
rules?: Promise<RuleCatalog>;
|
||||||
/** 作品原语言缓存(规则组 CNSUB/CNVOI 放行) */
|
/** 作品原语言缓存(规则组 CNSUB/CNVOI 放行) */
|
||||||
langs?: Map<string, Promise<string | undefined>>;
|
langs?: Map<string, Promise<string | undefined>>;
|
||||||
|
/** 现有资产在规则组下的 pri 缓存 */
|
||||||
|
assetPri?: Map<string, number | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function note(c: Ctx, base: Omit<CandidateEvaluation, "evaluatedAt">) {
|
async function note(c: Ctx, base: Omit<CandidateEvaluation, "evaluatedAt">) {
|
||||||
@@ -574,6 +579,10 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
|
|||||||
if (isMovie) {
|
if (isMovie) {
|
||||||
if (inflight.length > 0 || BUSY.includes(slot.status)) return defer("slot_busy", []);
|
if (inflight.length > 0 || BUSY.includes(slot.status)) return defer("slot_busy", []);
|
||||||
const current = await currentFacts(db, slot.current_asset_id);
|
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);
|
ev = evaluateCandidate(spec, parsed, slotSatisfied ? current : null, ctx);
|
||||||
} else {
|
} else {
|
||||||
const cov = await slotCoveredUnits(db, slot, { entityWide: p.slotCount <= 1 });
|
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);
|
return defer("units_inflight", reservedHit);
|
||||||
} else {
|
} else {
|
||||||
// 都已覆盖:分集洗版,只取能提升的单元(对照 MP get_best_version_interested_episodes)
|
// 都已覆盖:分集洗版,只取能提升的单元(对照 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 withCurrent = releaseKeys.filter((k) => unitCurrent[k]);
|
||||||
const firstCurrent = withCurrent.length
|
const firstCurrent = withCurrent.length
|
||||||
? unitCurrent[withCurrent[0]!]!
|
? unitCurrent[withCurrent[0]!]!
|
||||||
@@ -743,11 +757,13 @@ async function unitQuality(
|
|||||||
exec: Exec,
|
exec: Exec,
|
||||||
slot: AcquisitionSlotRow,
|
slot: AcquisitionSlotRow,
|
||||||
keys: readonly string[],
|
keys: readonly string[],
|
||||||
|
/** 版本目标引用了带层的规则组时,按规则组重算该单元现有资产的 pri(覆盖入库时按 soft.tiers 记下的值) */
|
||||||
|
priOf?: (assetId: string) => Promise<number | undefined>,
|
||||||
): Promise<Record<string, QualityFacts | undefined>> {
|
): Promise<Record<string, QualityFacts | undefined>> {
|
||||||
const rows = await exec
|
const rows = await exec
|
||||||
.selectFrom("slot_unit as su")
|
.selectFrom("slot_unit as su")
|
||||||
.innerJoin("content_unit as u", "u.id", "su.content_unit_id")
|
.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.slot_id", "=", slot.id)
|
||||||
.where("su.media_asset_id", "is not", null)
|
.where("su.media_asset_id", "is not", null)
|
||||||
.execute();
|
.execute();
|
||||||
@@ -757,11 +773,50 @@ async function unitQuality(
|
|||||||
if (r.episode_number === null) continue;
|
if (r.episode_number === null) continue;
|
||||||
const k = episodeKey(r.season_number ?? 1, r.episode_number);
|
const k = episodeKey(r.season_number ?? 1, r.episode_number);
|
||||||
if (!want.has(k)) continue;
|
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;
|
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<number | undefined> {
|
||||||
|
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 {
|
interface GateOk {
|
||||||
ok: true;
|
ok: true;
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
|
|||||||
@@ -225,6 +225,66 @@ describe.skipIf(!hasPg)("规则组 API(真实 PG)", () => {
|
|||||||
expect((await h.api("DELETE", `/rules/${g.body.data.id}`)).status).toBe(409);
|
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 () => {
|
it("默认链:订阅默认组有层时对未引用规则组的版本目标生效;PUT /rules/defaults 校验类型", async () => {
|
||||||
const defs = (await h.api("GET", "/rules/defaults")).body.data;
|
const defs = (await h.api("GET", "/rules/defaults")).body.data;
|
||||||
const dflt = defs.acquisition[0];
|
const dflt = defs.acquisition[0];
|
||||||
|
|||||||
Reference in new issue
Block a user