merge: ui/k-release-results
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
commit
d1575b6e21
7 files changed
+1098
-47
No files matched your search
@@ -16,6 +16,7 @@ import type { SourcesRuntime } from "../sources-identity/runtime.ts";
|
||||
import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
|
||||
import { siteCan } from "../sources-identity/site-kinds.ts";
|
||||
import { raceAbort } from "./media.ts";
|
||||
import { ruleVerdicts } from "./rule-verdict.ts";
|
||||
|
||||
// 跨站资源搜索:选中的已启用站点逐站调适配器 search(每站共享限流器 + 单站超时 + 站点级并发上限);
|
||||
// 按(站点, 查询词)并发合并、短缓存 —— 换站点选择时已查过的站点直接复用;结果 upsert 为资源
|
||||
@@ -23,7 +24,7 @@ import { raceAbort } from "./media.ts";
|
||||
// 作品关联与库存叠加在每次请求时现算(不进缓存),识别完成后刷新即可看到。
|
||||
|
||||
/** 缓存里的资源条目(不含作品关联与库存叠加) */
|
||||
export type BaseRelease = Omit<SearchRelease, "mediaEntityId" | "entityMatch" | "entity" | "inventory">;
|
||||
export type BaseRelease = Omit<SearchRelease, "mediaEntityId" | "entityMatch" | "entity" | "inventory" | "ruleVerdict">;
|
||||
|
||||
export interface Fanout {
|
||||
items: BaseRelease[];
|
||||
@@ -502,6 +503,18 @@ export async function decorate(
|
||||
entities.set(e.id, e);
|
||||
}
|
||||
const inv = entityIds.length ? await loadInventory(entityIds) : new Map<string, InventoryEntry>();
|
||||
// 规则判定:失败只记警告,不影响结果列表
|
||||
const verdicts = await ruleVerdicts(
|
||||
db,
|
||||
items.map((it) => {
|
||||
const link = links.get(it.key);
|
||||
const e = link ? entities.get(link.id) : undefined;
|
||||
return { key: it.key, sourceReleaseId: it.sourceReleaseId, entity: e ? { id: e.id, kind: e.kind } : null };
|
||||
}),
|
||||
).catch((err: Error) => {
|
||||
rt.ctx.logger.warn({ err: { message: err.message } }, "搜索结果规则判定失败");
|
||||
return new Map<string, never>();
|
||||
});
|
||||
return items.map((it) => {
|
||||
const link = links.get(it.key);
|
||||
const e = link ? entities.get(link.id) : undefined;
|
||||
@@ -512,6 +525,7 @@ export async function decorate(
|
||||
entityMatch: e ? link!.match : null,
|
||||
entity: e ? { id: e.id, title: e.title, year: e.year, kind: e.kind as never } : null,
|
||||
inventory: entry ? overlay(entry, it.sourceReleaseId) : null,
|
||||
ruleVerdict: verdicts.get(it.key) ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import type { SearchRuleVerdict } from "@umt/contracts";
|
||||
import type { AcquisitionSlotRow, DB, Kysely, SourceReleaseRow, SubscriptionRow } from "@umt/db";
|
||||
import {
|
||||
currentPri,
|
||||
describeViolation,
|
||||
effectiveLadder,
|
||||
evaluateCandidate,
|
||||
type QualityFacts,
|
||||
qualityTier,
|
||||
ruleChainOf,
|
||||
type SlotEvaluation,
|
||||
type SlotSpec,
|
||||
} from "@umt/domain";
|
||||
import { contextOf, currentFacts, parsedOf } from "../acquisition-organize/services/acquisition.ts";
|
||||
import { entityOriginalLanguage, loadRuleCatalog } from "../acquisition-organize/services/rules.ts";
|
||||
import { slotSpecWithRules } from "../acquisition-organize/services/subscriptions.ts";
|
||||
|
||||
// 搜索结果的规则判定:关联到本地作品的资源,按作品各收录版本的评估口径(硬条件 + 规则组链 + 阶梯,
|
||||
// 同自动获取的 evaluateCandidate)判断「命中第几档 / 不满足什么 / 比库里版本高几档」。
|
||||
// 只调用 acquisition-organize 导出的函数(规则目录、收录版本规格、资源 → 评估输入、当前版本特征);
|
||||
// 现有版本的 pri 重算(acquisition.ts 的 rulePriOfAsset 未导出)在这里按同一口径组合:
|
||||
// 有来源资源 → 按当前规则重算;否则取下载计划评估时记下的 pri;再否则按交付特征算。
|
||||
// 性能:一页最多判定 MAX_ITEMS 条、MAX_ENTITIES 个作品;收录版本、订阅、资源行、规则目录都是批量查一次。
|
||||
|
||||
export const MAX_VERDICT_ITEMS = 100;
|
||||
export const MAX_VERDICT_ENTITIES = 20;
|
||||
|
||||
export interface VerdictInput {
|
||||
key: string;
|
||||
sourceReleaseId: string | null;
|
||||
entity: { id: string; kind: string } | null;
|
||||
}
|
||||
|
||||
const FIELD_LABEL: Record<string, string> = {
|
||||
resolution: "分辨率",
|
||||
source: "来源",
|
||||
size: "体积",
|
||||
subtitles: "字幕",
|
||||
cut: "版本",
|
||||
group: "制作组",
|
||||
contentType: "内容类型",
|
||||
hdr: "HDR",
|
||||
codec: "编码",
|
||||
keywords: "关键字",
|
||||
site: "站点",
|
||||
seeders: "做种数",
|
||||
promotion: "促销",
|
||||
tier: "分档",
|
||||
rule: "规则组",
|
||||
};
|
||||
const fieldLabel = (f: string) => FIELD_LABEL[f] ?? f;
|
||||
|
||||
/** 收录版本有没有可判定的东西:硬条件、阶梯、带层或带硬排除的规则组(种子的默认组是空的,不算) */
|
||||
export function specHasRules(spec: SlotSpec): boolean {
|
||||
const hard = Object.values(spec.hard ?? {}).some((v) =>
|
||||
Array.isArray(v) ? v.length > 0 : v !== undefined && v !== null && v !== false,
|
||||
);
|
||||
const groups = !!spec.rules?.groups.some((g) => g.layers.length > 0 || (g.hardExcludes?.length ?? 0) > 0);
|
||||
return hard || groups || !!spec.soft?.tiers?.length;
|
||||
}
|
||||
|
||||
const tieredSpec = (spec: SlotSpec) =>
|
||||
!!spec.soft?.tiers?.length || !!spec.rules?.groups.some((g) => g.layers.length > 0);
|
||||
|
||||
interface Candidate {
|
||||
verdict: Omit<SearchRuleVerdict, "slotCount" | "vsLibrary">;
|
||||
slot: AcquisitionSlotRow;
|
||||
spec: SlotSpec;
|
||||
ev: SlotEvaluation;
|
||||
pri: number;
|
||||
}
|
||||
|
||||
/** 一个收录版本的评估 → 判定(不含库存比较) */
|
||||
export function verdictOf(slot: Pick<AcquisitionSlotRow, "id" | "name">, ev: SlotEvaluation) {
|
||||
const base = { slotId: slot.id, slotName: slot.name };
|
||||
if (ev.decision === "unknown_field") {
|
||||
const missing = (ev.missing as string[]).filter((f) => f !== "current_asset");
|
||||
return {
|
||||
...base,
|
||||
outcome: "unknown" as const,
|
||||
tier: null,
|
||||
tierName: null,
|
||||
failed: [...new Set(missing.map(fieldLabel))],
|
||||
details: missing.map((f) => `标题里看不出${fieldLabel(f)}`),
|
||||
};
|
||||
}
|
||||
if (ev.decision === "reject") {
|
||||
const failed = ev.violations.map((v) =>
|
||||
v.field === "rule" && typeof v.expected === "string" ? `规则组「${v.expected}」` : fieldLabel(v.field),
|
||||
);
|
||||
return {
|
||||
...base,
|
||||
outcome: "miss" as const,
|
||||
tier: null,
|
||||
tierName: null,
|
||||
failed: [...new Set(failed)],
|
||||
details: ev.violations.map(describeViolation),
|
||||
};
|
||||
}
|
||||
const level = ev.ladder?.level ?? null;
|
||||
return {
|
||||
...base,
|
||||
outcome: "hit" as const,
|
||||
tier: level === null ? null : level + 1,
|
||||
tierName: level === null ? null : (ev.ladder?.levelName ?? null),
|
||||
failed: [],
|
||||
details: [],
|
||||
};
|
||||
}
|
||||
|
||||
const RANK = { hit: 0, unknown: 1, miss: 2 } as const;
|
||||
|
||||
/**
|
||||
* 批量判定:返回 key → 判定。没关联作品、没有资源行、作品没有收录版本或收录版本都没配置规则的不出现在结果里。
|
||||
*/
|
||||
export async function ruleVerdicts(
|
||||
db: Kysely<DB>,
|
||||
items: readonly VerdictInput[],
|
||||
now: Date = new Date(),
|
||||
): Promise<Map<string, SearchRuleVerdict>> {
|
||||
const out = new Map<string, SearchRuleVerdict>();
|
||||
const entityIds: string[] = [];
|
||||
for (const it of items) {
|
||||
if (!it.entity || !it.sourceReleaseId) continue;
|
||||
if (!entityIds.includes(it.entity.id) && entityIds.length < MAX_VERDICT_ENTITIES) entityIds.push(it.entity.id);
|
||||
}
|
||||
const todo = items
|
||||
.filter((it) => it.entity && it.sourceReleaseId && entityIds.includes(it.entity.id))
|
||||
.slice(0, MAX_VERDICT_ITEMS);
|
||||
if (todo.length === 0) return out;
|
||||
|
||||
const slots = await db
|
||||
.selectFrom("acquisition_slot")
|
||||
.selectAll()
|
||||
.where("media_entity_id", "in", entityIds)
|
||||
.orderBy("position")
|
||||
.orderBy("created_at")
|
||||
.execute();
|
||||
if (slots.length === 0) return out;
|
||||
const subIds = [...new Set(slots.map((s) => s.subscription_id).filter((x): x is string => !!x))];
|
||||
const [subs, rels, cat] = await Promise.all([
|
||||
subIds.length
|
||||
? db.selectFrom("subscription").select(["id", "params"]).where("id", "in", subIds).execute()
|
||||
: Promise.resolve([] as Pick<SubscriptionRow, "id" | "params">[]),
|
||||
db
|
||||
.selectFrom("source_release")
|
||||
.selectAll()
|
||||
.where(
|
||||
"id",
|
||||
"in",
|
||||
todo.map((t) => t.sourceReleaseId!),
|
||||
)
|
||||
.execute(),
|
||||
loadRuleCatalog(db),
|
||||
]);
|
||||
const subById = new Map(subs.map((s) => [s.id, s]));
|
||||
const relById = new Map(rels.map((r) => [r.id, r]));
|
||||
const slotsByEntity = new Map<string, AcquisitionSlotRow[]>();
|
||||
for (const s of slots) slotsByEntity.set(s.media_entity_id, [...(slotsByEntity.get(s.media_entity_id) ?? []), s]);
|
||||
|
||||
const langs = new Map<string, Promise<string | undefined>>();
|
||||
const langOf = (id: string) => {
|
||||
if (!langs.has(id)) langs.set(id, entityOriginalLanguage(db, id));
|
||||
return langs.get(id)!;
|
||||
};
|
||||
const currentPriCache = new Map<string, Promise<{ facts: QualityFacts; pri: number | undefined } | null>>();
|
||||
|
||||
for (const it of todo) {
|
||||
const rel = relById.get(it.sourceReleaseId!);
|
||||
const entity = it.entity!;
|
||||
const entitySlots = slotsByEntity.get(entity.id) ?? [];
|
||||
if (!rel || entitySlots.length === 0) continue;
|
||||
const parsed = parsedOf(rel);
|
||||
const cands: Candidate[] = [];
|
||||
let ruled = 0;
|
||||
for (const slot of entitySlots) {
|
||||
const sub = slot.subscription_id ? subById.get(slot.subscription_id) : null;
|
||||
const spec = slotSpecWithRules(cat, slot, sub, entity, rel.site_id);
|
||||
if (!specHasRules(spec)) continue;
|
||||
ruled++;
|
||||
const ctx = contextOf(rel, now);
|
||||
if (spec.rules) {
|
||||
const lang = await langOf(entity.id);
|
||||
if (lang) ctx.originalLanguage = lang;
|
||||
}
|
||||
const ev = evaluateCandidate(spec, parsed, null, ctx);
|
||||
const pri = ev.decision === "accept" || ev.decision === "upgrade" ? (ev.pri ?? ev.ladder?.pri ?? 100) : -1;
|
||||
cands.push({ verdict: verdictOf(slot, ev), slot, spec, ev, pri });
|
||||
}
|
||||
if (cands.length === 0) continue;
|
||||
// 多个收录版本:命中 > 缺字段 > 不满足;同为命中取档位高的;再按收录版本顺序
|
||||
const best = [...cands].sort((a, b) => RANK[a.verdict.outcome] - RANK[b.verdict.outcome] || b.pri - a.pri)[0]!;
|
||||
let vsLibrary: SearchRuleVerdict["vsLibrary"] = null;
|
||||
if (best.verdict.outcome === "hit" && best.slot.current_asset_id) {
|
||||
const key = `${best.slot.id}:${rel.site_id}`;
|
||||
if (!currentPriCache.has(key))
|
||||
currentPriCache.set(key, currentVersion(db, best.spec, best.slot.current_asset_id, entity.id, langOf, now));
|
||||
const cur = await currentPriCache.get(key)!;
|
||||
if (cur) vsLibrary = compareWithLibrary(best.spec, parsed, best.pri, cur.facts, cur.pri);
|
||||
}
|
||||
out.set(it.key, { ...best.verdict, slotCount: ruled, vsLibrary });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 当前版本的交付特征 + 规则 pri(同 acquisition.ts rulePriOfAsset 的口径,只在分档时算) */
|
||||
async function currentVersion(
|
||||
db: Kysely<DB>,
|
||||
spec: SlotSpec,
|
||||
assetId: string,
|
||||
entityId: string,
|
||||
langOf: (id: string) => Promise<string | undefined>,
|
||||
now: Date,
|
||||
): Promise<{ facts: QualityFacts; pri: number | undefined } | null> {
|
||||
const facts = await currentFacts(db, assetId);
|
||||
if (!facts) return null;
|
||||
if (!tieredSpec(spec)) return { facts, pri: undefined };
|
||||
const a = await db
|
||||
.selectFrom("media_asset")
|
||||
.select(["source_release_id", "download_task_id"])
|
||||
.where("id", "=", assetId)
|
||||
.executeTakeFirst();
|
||||
let rel: SourceReleaseRow | undefined;
|
||||
if (a?.source_release_id)
|
||||
rel = await db.selectFrom("source_release").selectAll().where("id", "=", a.source_release_id).executeTakeFirst();
|
||||
if (rel) {
|
||||
const p = parsedOf(rel);
|
||||
const lang = spec.rules ? await langOf(entityId) : undefined;
|
||||
const ctx = { ...contextOf(rel, now), ...(lang ? { originalLanguage: lang } : {}) };
|
||||
return { facts, pri: effectiveLadder(spec, p, ctx, ruleChainOf(spec, p, ctx)).pri };
|
||||
}
|
||||
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") return { facts, pri: v };
|
||||
}
|
||||
return { facts, pri: currentPri(spec, facts) };
|
||||
}
|
||||
|
||||
/** 分档时比 pri(相差几档);不分档时比来源等级与分辨率(两项同向才下结论) */
|
||||
export function compareWithLibrary(
|
||||
spec: SlotSpec,
|
||||
candidate: Pick<QualityFacts, "resolution" | "source">,
|
||||
candidatePri: number,
|
||||
current: QualityFacts,
|
||||
currentRulePri: number | undefined,
|
||||
): SearchRuleVerdict["vsLibrary"] {
|
||||
if (tieredSpec(spec) && currentRulePri !== undefined) {
|
||||
const d = candidatePri - currentRulePri;
|
||||
return { cmp: d > 0 ? "higher" : d < 0 ? "lower" : "same", steps: Math.abs(d) };
|
||||
}
|
||||
const a = qualityTier(candidate, spec);
|
||||
const b = qualityTier(current, spec);
|
||||
if (a.source < 0 || a.resolution < 0 || b.source < 0 || b.resolution < 0) return null;
|
||||
const ds = a.source - b.source;
|
||||
const dr = a.resolution - b.resolution;
|
||||
if (ds === 0 && dr === 0) return { cmp: "same", steps: null };
|
||||
if (ds >= 0 && dr >= 0) return { cmp: "higher", steps: null };
|
||||
if (ds <= 0 && dr <= 0) return { cmp: "lower", steps: null };
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import type { DB, Kysely } from "@umt/db";
|
||||
import { parseRelease } from "@umt/domain";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { type BaseRelease, decorate } from "../../src/modules/search/releases.ts";
|
||||
import { compareWithLibrary, specHasRules } from "../../src/modules/search/rule-verdict.ts";
|
||||
import type { SourcesRuntime } from "../../src/modules/sources-identity/runtime.ts";
|
||||
import { memoryLogger } from "../helpers.ts";
|
||||
import { createTestSchema, pgAvailable, type TestSchema, uniqueSchema } from "../support/pg.ts";
|
||||
|
||||
// 搜索结果的规则判定:关联到本地作品的资源按收录版本评估口径给出「命中第 N 档 / 不满足 / 比库里版本高几档」;
|
||||
// 没关联作品、作品没有收录版本、收录版本没配置条件时为 null。
|
||||
|
||||
const up = await pgAvailable();
|
||||
|
||||
describe("规则判定口径", () => {
|
||||
it("种子默认规则组(空层)不算规则;硬条件或带层规则组才算", () => {
|
||||
expect(specHasRules({ name: "x", hard: {} })).toBe(false);
|
||||
expect(
|
||||
specHasRules({
|
||||
name: "x",
|
||||
hard: {},
|
||||
rules: { groups: [{ name: "订阅默认", layers: [], hardExcludes: [] }], atoms: [] },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(specHasRules({ name: "x", hard: { resolutions: ["2160p"] } })).toBe(true);
|
||||
expect(specHasRules({ name: "x", hard: {}, soft: { tiers: [{ resolutions: ["2160p"] }] } })).toBe(true);
|
||||
});
|
||||
|
||||
it("不分档时按来源等级与分辨率比较,两项不同向不下结论", () => {
|
||||
const spec = { name: "x", hard: {} };
|
||||
expect(
|
||||
compareWithLibrary(
|
||||
spec,
|
||||
{ resolution: "2160p", source: "BluRay" },
|
||||
100,
|
||||
{ resolution: "1080p", source: "WEB-DL" },
|
||||
undefined,
|
||||
),
|
||||
).toEqual({ cmp: "higher", steps: null });
|
||||
expect(
|
||||
compareWithLibrary(
|
||||
spec,
|
||||
{ resolution: "1080p", source: "WEB-DL" },
|
||||
100,
|
||||
{ resolution: "1080p", source: "WEB-DL" },
|
||||
undefined,
|
||||
),
|
||||
).toEqual({ cmp: "same", steps: null });
|
||||
expect(
|
||||
compareWithLibrary(
|
||||
spec,
|
||||
{ resolution: "720p", source: "HDTV" },
|
||||
100,
|
||||
{ resolution: "1080p", source: "WEB-DL" },
|
||||
undefined,
|
||||
),
|
||||
).toEqual({ cmp: "lower", steps: null });
|
||||
expect(
|
||||
compareWithLibrary(
|
||||
spec,
|
||||
{ resolution: "2160p", source: "WEB-DL" },
|
||||
100,
|
||||
{ resolution: "1080p", source: "BluRay" },
|
||||
undefined,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!up)("搜索结果规则判定", () => {
|
||||
let db: Kysely<DB>;
|
||||
let ts: TestSchema;
|
||||
let rt: SourcesRuntime;
|
||||
let siteId = "";
|
||||
let entity = "";
|
||||
let bare = "";
|
||||
const rel: Record<string, string> = {};
|
||||
|
||||
const TITLES = {
|
||||
uhd: "Dune.Part.Two.2024.2160p.BluRay.REMUX.HEVC.DTS-HD.MA-FRDS",
|
||||
fhd: "Dune.Part.Two.2024.1080p.WEB-DL.H264-GRP",
|
||||
sd: "Dune.Part.Two.2024.720p.HDTV.x264-GRP",
|
||||
other: "Some.Other.Movie.2023.1080p.WEB-DL.H264-GRP",
|
||||
loose: "Unrelated.Thing.2020.1080p.WEB-DL-X",
|
||||
} as const;
|
||||
|
||||
const base = (k: keyof typeof TITLES): BaseRelease => {
|
||||
const p = parseRelease(TITLES[k]);
|
||||
return {
|
||||
key: `${siteId}:${k}`,
|
||||
sourceReleaseId: rel[k]!,
|
||||
siteId,
|
||||
siteName: "站",
|
||||
siteReleaseId: k,
|
||||
title: TITLES[k],
|
||||
subtitle: null,
|
||||
category: { id: null, name: null },
|
||||
sizeBytes: null,
|
||||
seeders: 10,
|
||||
leechers: 0,
|
||||
snatched: null,
|
||||
promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null },
|
||||
free: false,
|
||||
hr: false,
|
||||
uploadedAt: null,
|
||||
detailUrl: "http://x/",
|
||||
observedAt: "2026-10-01T00:00:00.000Z",
|
||||
parsed: {
|
||||
titleCn: null,
|
||||
titleEn: p.titleEn ?? null,
|
||||
year: p.year ?? null,
|
||||
season: null,
|
||||
episodes: null,
|
||||
resolution: p.resolution ?? null,
|
||||
source: p.source ?? null,
|
||||
videoCodec: p.videoCodec ?? null,
|
||||
hdr: [],
|
||||
releaseGroup: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
ts = await createTestSchema(uniqueSchema("rule_verdict"), { max: 4 });
|
||||
db = ts.db as unknown as Kysely<DB>;
|
||||
rt = { ctx: { db, logger: memoryLogger().logger } } as unknown as SourcesRuntime;
|
||||
|
||||
siteId = (
|
||||
await db
|
||||
.insertInto("site")
|
||||
.values({ key: "s1", name: "站", adapter: "nexusphp", base_url: "http://s1.invalid/" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
const addEntity = async (title: string) =>
|
||||
(
|
||||
await db
|
||||
.insertInto("media_entity")
|
||||
.values({ kind: "movie", title, year: 2024, status: "active" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
entity = await addEntity("沙丘2");
|
||||
bare = await addEntity("没有收录版本的作品");
|
||||
for (const k of Object.keys(TITLES) as (keyof typeof TITLES)[]) {
|
||||
const linked = k === "other" ? bare : k === "loose" ? null : entity;
|
||||
rel[k] = (
|
||||
await db
|
||||
.insertInto("source_release")
|
||||
.values({
|
||||
site_id: siteId,
|
||||
site_release_id: k,
|
||||
title: TITLES[k],
|
||||
seeders: 10,
|
||||
media_entity_id: linked,
|
||||
resolution_status: linked ? "resolved" : "unresolved",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
}
|
||||
// 规则组:第 1 档 4K,第 2 档 1080P
|
||||
const group = (
|
||||
await db
|
||||
.insertInto("rule_group")
|
||||
.values({
|
||||
name: "画质",
|
||||
kind: "acquisition",
|
||||
fingerprint: "test:quality",
|
||||
layers: JSON.stringify([{ expr: { op: "atom", id: "4K" } }, { expr: { op: "atom", id: "1080P" } }]),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
// 库里现有版本来自 1080p 那条资源(第 2 档)
|
||||
const asset = (
|
||||
await db
|
||||
.insertInto("media_asset")
|
||||
.values({ media_entity_id: entity, kind: "video", source_release_id: rel.fhd!, status: "active" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
await db
|
||||
.insertInto("acquisition_slot")
|
||||
.values({
|
||||
media_entity_id: entity,
|
||||
name: "主版本",
|
||||
status: "satisfied",
|
||||
hard_conditions: JSON.stringify({ resolutions: ["2160p", "1080p"] }),
|
||||
rule_group_ids: JSON.stringify([group]),
|
||||
current_asset_id: asset,
|
||||
})
|
||||
.execute();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await ts?.drop();
|
||||
});
|
||||
|
||||
it("命中档位 / 不满足原因 / 与库里版本比较;无作品或作品没有收录版本时为 null", async () => {
|
||||
const items = await decorate(
|
||||
rt,
|
||||
(["uhd", "fhd", "sd", "other", "loose"] as const).map(base),
|
||||
async () => new Map(),
|
||||
);
|
||||
const by = Object.fromEntries(items.map((i) => [i.siteReleaseId, i]));
|
||||
expect(by.uhd!.ruleVerdict).toMatchObject({
|
||||
slotName: "主版本",
|
||||
slotCount: 1,
|
||||
outcome: "hit",
|
||||
tier: 1,
|
||||
tierName: "画质·第1层",
|
||||
failed: [],
|
||||
vsLibrary: { cmp: "higher", steps: 1 },
|
||||
});
|
||||
expect(by.fhd!.ruleVerdict).toMatchObject({ outcome: "hit", tier: 2, vsLibrary: { cmp: "same", steps: 0 } });
|
||||
const sd = by.sd!.ruleVerdict!;
|
||||
expect(sd).toMatchObject({ outcome: "miss", tier: null, vsLibrary: null });
|
||||
expect(sd.failed).toContain("分辨率");
|
||||
expect(sd.failed).toContain("规则组「画质」");
|
||||
expect(sd.details.length).toBeGreaterThan(0);
|
||||
// 关联到没有收录版本的作品
|
||||
expect(by.other!.entity?.id).toBe(bare);
|
||||
expect(by.other!.ruleVerdict).toBeNull();
|
||||
// 没关联作品
|
||||
expect(by.loose!.entity).toBeNull();
|
||||
expect(by.loose!.ruleVerdict).toBeNull();
|
||||
});
|
||||
|
||||
it("收录版本只有种子默认规则组(没有条件)时不给判定", async () => {
|
||||
const e = (
|
||||
await db
|
||||
.insertInto("media_entity")
|
||||
.values({ kind: "movie", title: "空规则作品", year: 2024, status: "active" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
await db.insertInto("acquisition_slot").values({ media_entity_id: e, name: "默认版本" }).execute();
|
||||
await db.updateTable("source_release").set({ media_entity_id: e }).where("id", "=", rel.loose!).execute();
|
||||
const [item] = await decorate(rt, [base("loose")], async () => new Map());
|
||||
expect(item!.entity?.id).toBe(e);
|
||||
expect(item!.ruleVerdict).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
PackageSearch,
|
||||
RefreshCw,
|
||||
TriangleAlert,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
@@ -25,7 +26,7 @@ import { Input } from "~/components/ui/input.tsx";
|
||||
import { Pagination } from "~/components/ui/pagination.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { absTime, relTime, remainingLabel } from "~/components/workbench/release-fields.ts";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
import { QuickFilterBar } from "./quick-filter-bar.tsx";
|
||||
@@ -37,11 +38,25 @@ import {
|
||||
useQuickFilters,
|
||||
} from "./quick-filter-state.ts";
|
||||
import { delegateToAgent, SearchDownloadDialog } from "./search-download-dialog.tsx";
|
||||
import { gbToBytes, INVENTORY_STATE, promoLabel, SITE_STATUS, SORT_OPTIONS } from "./search-labels.ts";
|
||||
import {
|
||||
gbToBytes,
|
||||
groupByWork,
|
||||
hrBadge,
|
||||
INVENTORY_STATE,
|
||||
promoBadge,
|
||||
releaseSpecTags,
|
||||
SITE_STATUS,
|
||||
SORT_OPTIONS,
|
||||
verdictView,
|
||||
vsLibraryLabel,
|
||||
type WorkGroup,
|
||||
} from "./search-labels.ts";
|
||||
|
||||
// 资源段(SEARCH-2 第二阶段):按作品或关键词、在选中的站点上搜;快速筛选(SEARCH-3:分辨率/编码/版本/站点/
|
||||
// 促销/HDR/H&R/制作组/季集,多选带计数),同一行右侧是大小区间与排序(POLISH-1,手机收进抽屉);游标翻页;
|
||||
// 行操作同站点工作台(库存叠加、下载、委托智能体)。耗时、查询词放进「技术信息」。
|
||||
// SEARCH-5(参考 MP 资源卡片):促销放标题前、规格拆成中性标签、关键数字定宽对齐、按作品分组,
|
||||
// 每行附规则判定(命中第几档 / 不满足什么 / 与库里版本比较,服务端 ruleVerdict)。
|
||||
|
||||
/** URL 里的资源筛选(字符串形态,空串 = 不筛) */
|
||||
export type ReleaseFilterState = {
|
||||
@@ -237,11 +252,7 @@ export function ReleaseResults({
|
||||
className="py-12"
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{paged.items?.map((r) => (
|
||||
<ReleaseRow key={r.key} r={r} onOpen={() => setSelected(r)} onDownload={() => setDl(r)} />
|
||||
))}
|
||||
</ul>
|
||||
<ReleaseList rows={paged.items ?? []} onOpen={setSelected} onDownload={setDl} />
|
||||
)}
|
||||
{rows.length > 0 || res.hasNextPage ? <Pagination {...paged.pagination} /> : null}
|
||||
</div>
|
||||
@@ -420,14 +431,141 @@ function SizeSortControls({
|
||||
);
|
||||
}
|
||||
|
||||
function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () => void; onDownload: () => void }) {
|
||||
const promo = promoLabel(r.promotion.type);
|
||||
const inv = r.inventory ? INVENTORY_STATE[r.inventory.state] : null;
|
||||
const p = r.parsed;
|
||||
const tech = [p.resolution, p.source, p.videoCodec, ...p.hdr, p.releaseGroup].filter(Boolean).join(" · ");
|
||||
/** 作品组头:作品名 + 年份 + 未确认 + 库存状态 + 条数 */
|
||||
function WorkHeader({ g, count }: { g: WorkGroup; count: number }) {
|
||||
const inv = g.inventory ? INVENTORY_STATE[g.inventory.state] : null;
|
||||
return (
|
||||
<li className="px-4 py-3">
|
||||
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 bg-foreground/[0.03] px-4 py-2">
|
||||
{g.entity ? (
|
||||
<>
|
||||
<Link
|
||||
to={`/console/library/${g.entity.id}`}
|
||||
className="min-w-0 truncate text-caption font-semibold text-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
{g.entity.title}
|
||||
{g.entity.year ? ` (${g.entity.year})` : ""}
|
||||
</Link>
|
||||
{g.entityMatch === "alias" ? <StatusBadge>未确认</StatusBadge> : null}
|
||||
{inv ? <StatusBadge tone={inv.tone}>{inv.label}</StatusBadge> : null}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-caption font-semibold text-muted-foreground">未关联本地作品</span>
|
||||
)}
|
||||
<span className="ml-auto text-meta text-muted-foreground tabular-nums">{count} 条</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 按作品分组的结果列表;只涉及一部作品时作品名只在上方出现一次 */
|
||||
function ReleaseList({
|
||||
rows,
|
||||
onOpen,
|
||||
onDownload,
|
||||
}: {
|
||||
rows: SearchRelease[];
|
||||
onOpen: (r: SearchRelease) => void;
|
||||
onDownload: (r: SearchRelease) => void;
|
||||
}) {
|
||||
const { grouped, groups } = groupByWork(rows);
|
||||
const single = !grouped && groups[0]?.entity ? groups[0] : null;
|
||||
const row = (r: SearchRelease) => (
|
||||
<ReleaseRow key={r.key} r={r} onOpen={() => onOpen(r)} onDownload={() => onDownload(r)} />
|
||||
);
|
||||
if (!grouped)
|
||||
return (
|
||||
<>
|
||||
{single ? <WorkHeader g={single} count={single.items.length} /> : null}
|
||||
<ul className="divide-y divide-border/60">{rows.map(row)}</ul>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<div className="divide-y divide-border/60">
|
||||
{groups.map((g) => (
|
||||
<section key={g.key ?? "loose"} aria-label={g.entity ? g.entity.title : "未关联本地作品"}>
|
||||
<WorkHeader g={g} count={g.items.length} />
|
||||
<ul className="divide-y divide-border/60 border-t border-border/60">{g.items.map(row)}</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 促销放在标题前:免费用 success(属状态),其余折扣中性加粗;带剩余时间 */
|
||||
function PromoTag({ r }: { r: SearchRelease }) {
|
||||
const promo = promoBadge(r);
|
||||
if (!promo) return null;
|
||||
const left = remainingLabel(r.promotion.expiresAt);
|
||||
const text = left ? `${promo.label} · ${left}` : promo.label;
|
||||
const title = r.promotion.expiresAt ? `促销截止 ${absTime(r.promotion.expiresAt)}` : undefined;
|
||||
return promo.tone === "success" ? (
|
||||
<span title={title} className="shrink-0">
|
||||
<StatusBadge tone="success" className="font-semibold">
|
||||
{text}
|
||||
</StatusBadge>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
title={title}
|
||||
className="inline-flex shrink-0 items-center rounded-full border border-foreground/30 px-2 py-0.5 text-meta font-semibold whitespace-nowrap text-foreground"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 规格标签:中性小标签,固定顺序;制作组带图标区分 */
|
||||
function SpecTagList({ r }: { r: SearchRelease }) {
|
||||
const tags = releaseSpecTags(r.parsed);
|
||||
if (!tags.length) return null;
|
||||
return (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{tags.map((t) => (
|
||||
<span
|
||||
key={t.key}
|
||||
title={t.title}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-0.5 rounded-md border px-1.5 text-meta leading-5 whitespace-nowrap",
|
||||
t.dim === "resolution" || t.dim === "hdr" || t.dim === "episode"
|
||||
? "border-border text-foreground"
|
||||
: "border-border/70 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t.dim === "group" ? <UsersRound aria-hidden className="h-3 w-3" /> : null}
|
||||
{t.label}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 规则判定 + 与库里版本比较 */
|
||||
function VerdictLine({ r }: { r: SearchRelease }) {
|
||||
const v = r.ruleVerdict;
|
||||
if (!v) return null;
|
||||
const view = verdictView(v);
|
||||
const vs = vsLibraryLabel(v);
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
<span title={view.title || undefined}>
|
||||
<StatusBadge tone={view.tone}>{view.label}</StatusBadge>
|
||||
</span>
|
||||
{vs ? <StatusBadge>{vs}</StatusBadge> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源行:左边 = 促销 + 标题 / 规格标签 / 规则判定;右边 = 站点、大小、做种·下载、发布时间定宽对齐,
|
||||
* 下面是操作。宽屏像表格一样上下对齐,窄屏右栏折到下面。
|
||||
*/
|
||||
function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () => void; onDownload: () => void }) {
|
||||
const hr = hrBadge(r);
|
||||
const seedless = r.seeders === 0 || r.seeders === null;
|
||||
return (
|
||||
<li className="flex min-w-0 flex-col gap-2 px-4 py-3 lg:flex-row lg:items-start lg:gap-6">
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<PromoTag r={r} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
@@ -436,44 +574,37 @@ function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () =>
|
||||
>
|
||||
{r.title}
|
||||
</button>
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{promo ? <StatusBadge tone={r.free ? "success" : "neutral"}>{promo}</StatusBadge> : null}
|
||||
{r.hr === true ? <StatusBadge tone="warning">H&R</StatusBadge> : null}
|
||||
{r.hr === "unknown" ? <StatusBadge>H&R 未知</StatusBadge> : null}
|
||||
{hr ? (
|
||||
<span className="shrink-0">
|
||||
<StatusBadge tone={hr.tone}>{hr.label}</StatusBadge>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{r.subtitle ? <p className="mt-0.5 line-clamp-1 text-caption text-muted-foreground">{r.subtitle}</p> : null}
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums">
|
||||
<span className="font-medium text-foreground">{r.siteName}</span>
|
||||
{r.category.name ? <span>{r.category.name}</span> : null}
|
||||
<span>{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}</span>
|
||||
<span className="inline-flex items-center gap-0.5" title="做种 / 下载">
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
{r.subtitle ? <p className="line-clamp-1 text-caption text-muted-foreground">{r.subtitle}</p> : null}
|
||||
<SpecTagList r={r} />
|
||||
<VerdictLine r={r} />
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-2 lg:items-end">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-meta text-muted-foreground tabular-nums lg:flex-nowrap">
|
||||
<span className="truncate font-medium text-foreground lg:w-24" title={r.category.name ?? undefined}>
|
||||
{r.siteName}
|
||||
</span>
|
||||
<span className="lg:w-16 lg:text-right">{r.sizeBytes === null ? "—" : formatBytes(r.sizeBytes, 1)}</span>
|
||||
<span className="inline-flex items-center gap-0.5 lg:w-28" title="做种 / 下载">
|
||||
<ArrowUp aria-label="做种" className="h-3.5 w-3.5" />
|
||||
<span className={cn("text-body", seedless ? "text-muted-foreground" : "font-semibold text-foreground")}>
|
||||
{r.seeders ?? "—"}
|
||||
<ArrowDown className="ml-1 h-3 w-3" />
|
||||
</span>
|
||||
<ArrowDown aria-label="下载" className="ml-1.5 h-3 w-3" />
|
||||
{r.leechers ?? "—"}
|
||||
</span>
|
||||
<span title="站点发布时间">发布 {relTime(r.uploadedAt)}</span>
|
||||
{tech ? <span className="break-all">{tech}</span> : null}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-1.5 text-meta">
|
||||
{r.entity ? (
|
||||
<>
|
||||
<Link
|
||||
to={`/console/library/${r.entity.id}`}
|
||||
className="truncate text-foreground underline-offset-4 hover:underline"
|
||||
<span
|
||||
className="lg:w-20 lg:text-right"
|
||||
title={r.uploadedAt ? `发布于 ${absTime(r.uploadedAt)}` : "发布时间未知"}
|
||||
>
|
||||
{r.entity.title}
|
||||
{r.entity.year ? ` (${r.entity.year})` : ""}
|
||||
</Link>
|
||||
{r.entityMatch === "alias" ? <StatusBadge>未确认</StatusBadge> : null}
|
||||
{inv ? <StatusBadge tone={inv.tone}>{inv.label}</StatusBadge> : null}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">未关联本地作品</span>
|
||||
)}
|
||||
{relTime(r.uploadedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
{r.sourceReleaseId ? (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { SearchRelease, SearchRuleVerdict } from "@umt/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { groupByWork, hrBadge, promoBadge, releaseSpecTags, verdictView, vsLibraryLabel } from "./search-labels.ts";
|
||||
|
||||
// SEARCH-5 资源行:规格标签拆分与顺序、促销 / H&R、按作品分组、规则判定文案。
|
||||
|
||||
const E1 = "11111111-1111-4111-8111-111111111111";
|
||||
const E2 = "22222222-2222-4222-8222-222222222222";
|
||||
const SLOT = "33333333-3333-4333-8333-333333333333";
|
||||
|
||||
const parsed = (p: Partial<SearchRelease["parsed"]> = {}): SearchRelease["parsed"] => ({
|
||||
titleCn: null,
|
||||
titleEn: null,
|
||||
year: null,
|
||||
season: null,
|
||||
episodes: null,
|
||||
resolution: null,
|
||||
source: null,
|
||||
videoCodec: null,
|
||||
hdr: [],
|
||||
releaseGroup: null,
|
||||
...p,
|
||||
});
|
||||
|
||||
const rel = (key: string, p: Partial<SearchRelease> = {}): SearchRelease => ({
|
||||
key,
|
||||
sourceReleaseId: null,
|
||||
siteId: E1,
|
||||
siteName: "站",
|
||||
siteReleaseId: key,
|
||||
title: key,
|
||||
subtitle: null,
|
||||
category: { id: null, name: null },
|
||||
sizeBytes: null,
|
||||
seeders: null,
|
||||
leechers: null,
|
||||
snatched: null,
|
||||
promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null },
|
||||
free: false,
|
||||
hr: false,
|
||||
uploadedAt: null,
|
||||
detailUrl: "http://x/",
|
||||
observedAt: "2026-10-01T00:00:00.000Z",
|
||||
parsed: parsed(),
|
||||
mediaEntityId: null,
|
||||
entityMatch: null,
|
||||
entity: null,
|
||||
inventory: null,
|
||||
ruleVerdict: null,
|
||||
...p,
|
||||
});
|
||||
|
||||
const verdict = (v: Partial<SearchRuleVerdict> = {}): SearchRuleVerdict => ({
|
||||
slotId: SLOT,
|
||||
slotName: "主版本",
|
||||
slotCount: 1,
|
||||
outcome: "hit",
|
||||
tier: 1,
|
||||
tierName: "画质·第1层",
|
||||
failed: [],
|
||||
details: [],
|
||||
vsLibrary: null,
|
||||
...v,
|
||||
});
|
||||
|
||||
describe("规格标签", () => {
|
||||
it("固定顺序:季集 → 分辨率 → 来源 → 编码 → HDR → 音频 → 语言 → 制作组", () => {
|
||||
const tags = releaseSpecTags(
|
||||
parsed({
|
||||
season: 1,
|
||||
episodes: { start: 1, end: 8 },
|
||||
releaseGroup: "CHDWEB",
|
||||
subtitles: ["zh-Hans", "en"],
|
||||
audioLangs: ["cmn"],
|
||||
audio: ["DDP 5.1", "Atmos"],
|
||||
hdr: ["DV", "HDR10"],
|
||||
videoCodec: "H.265",
|
||||
source: "WEB-DL",
|
||||
resolution: "2160p",
|
||||
}),
|
||||
);
|
||||
expect(tags.map((t) => t.label)).toEqual([
|
||||
"S01E01-E08",
|
||||
"2160p",
|
||||
"WEB-DL",
|
||||
"H.265",
|
||||
"DV",
|
||||
"HDR10",
|
||||
"DDP 5.1",
|
||||
"Atmos",
|
||||
"国语",
|
||||
"简中",
|
||||
"英字",
|
||||
"CHDWEB",
|
||||
]);
|
||||
expect(tags.map((t) => t.dim)).toEqual([
|
||||
"episode",
|
||||
"resolution",
|
||||
"source",
|
||||
"codec",
|
||||
"hdr",
|
||||
"hdr",
|
||||
"audio",
|
||||
"audio",
|
||||
"lang",
|
||||
"lang",
|
||||
"lang",
|
||||
"group",
|
||||
]);
|
||||
expect(tags[1]!.title).toBe("分辨率:2160p");
|
||||
});
|
||||
|
||||
it("没解析出的维度不出标签;同维度同名去重;多季合集写 S01-S03", () => {
|
||||
expect(releaseSpecTags(parsed())).toEqual([]);
|
||||
expect(releaseSpecTags(parsed({ hdr: ["DV", "DV"] })).map((t) => t.label)).toEqual(["DV"]);
|
||||
expect(releaseSpecTags(parsed({ season: 1, seasonEnd: 3 }))[0]!.label).toBe("S01-S03");
|
||||
});
|
||||
});
|
||||
|
||||
describe("促销与 H&R", () => {
|
||||
it("免费 / 2x 免费用 success;50%、2x 上传中性;无促销不显示", () => {
|
||||
expect(promoBadge(rel("a", { promotion: { ...rel("a").promotion, type: "free" }, free: true }))).toEqual({
|
||||
label: "免费",
|
||||
tone: "success",
|
||||
});
|
||||
expect(promoBadge(rel("a", { promotion: { ...rel("a").promotion, type: "2xfree" }, free: true }))).toEqual({
|
||||
label: "2x 免费",
|
||||
tone: "success",
|
||||
});
|
||||
expect(promoBadge(rel("a", { promotion: { ...rel("a").promotion, type: "50%" } }))).toEqual({
|
||||
label: "50%",
|
||||
tone: "neutral",
|
||||
});
|
||||
expect(promoBadge(rel("a", { promotion: { ...rel("a").promotion, type: "2x" } }))?.tone).toBe("neutral");
|
||||
expect(promoBadge(rel("a"))).toBeNull();
|
||||
});
|
||||
|
||||
it("H&R:有 → warning;未知 → 中性;没有 → 不显示", () => {
|
||||
expect(hrBadge({ hr: true })).toEqual({ label: "H&R", tone: "warning" });
|
||||
expect(hrBadge({ hr: "unknown" })).toEqual({ label: "H&R 未知", tone: "neutral" });
|
||||
expect(hrBadge({ hr: false })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("按作品分组", () => {
|
||||
const work = (id: string, title: string) => ({ id, title, year: 2024, kind: "movie" as const });
|
||||
it("多部作品按首次出现分组,未关联的放最后", () => {
|
||||
const rows = [
|
||||
rel("a", { entity: work(E1, "沙丘2"), entityMatch: "alias" }),
|
||||
rel("b"),
|
||||
rel("c", { entity: work(E2, "奥本海默"), entityMatch: "resolved" }),
|
||||
rel("d", { entity: work(E1, "沙丘2"), entityMatch: "resolved" }),
|
||||
];
|
||||
const { grouped, groups } = groupByWork(rows);
|
||||
expect(grouped).toBe(true);
|
||||
expect(groups.map((g) => [g.key, g.items.map((r) => r.key)])).toEqual([
|
||||
[E1, ["a", "d"]],
|
||||
[E2, ["c"]],
|
||||
[null, ["b"]],
|
||||
]);
|
||||
// 组里有已识别的,组头不再标「未确认」
|
||||
expect(groups[0]!.entityMatch).toBe("resolved");
|
||||
});
|
||||
|
||||
it("只有一部作品 / 全部未关联时不分组", () => {
|
||||
expect(
|
||||
groupByWork([rel("a", { entity: work(E1, "沙丘2") }), rel("b", { entity: work(E1, "沙丘2") })]),
|
||||
).toMatchObject({
|
||||
grouped: false,
|
||||
groups: [{ key: E1 }],
|
||||
});
|
||||
expect(groupByWork([rel("a"), rel("b")])).toMatchObject({ grouped: false, groups: [{ key: null }] });
|
||||
expect(groupByWork([])).toEqual({ grouped: false, groups: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("规则判定文案", () => {
|
||||
it("命中档位 / 满足 / 不满足 / 无法判断", () => {
|
||||
expect(verdictView(verdict())).toMatchObject({ label: "命中第 1 档", tone: "success" });
|
||||
expect(verdictView(verdict({ tier: null, tierName: null })).label).toBe("满足收录条件");
|
||||
const miss = verdictView(
|
||||
verdict({ outcome: "miss", tier: null, failed: ["分辨率", "来源"], details: ["分辨率 720p 不在允许列表"] }),
|
||||
);
|
||||
expect(miss).toMatchObject({ label: "不满足:分辨率、来源", tone: "neutral" });
|
||||
expect(miss.title).toContain("分辨率 720p 不在允许列表");
|
||||
expect(verdictView(verdict({ outcome: "miss", tier: null, failed: ["分辨率", "来源", "编码", "HDR"] })).label).toBe(
|
||||
"不满足:分辨率、来源、编码 等 4 项",
|
||||
);
|
||||
expect(verdictView(verdict({ outcome: "unknown", tier: null, failed: ["分辨率"] })).label).toBe("无法判断:分辨率");
|
||||
});
|
||||
|
||||
it("多个收录版本时带收录版本名", () => {
|
||||
expect(verdictView(verdict({ slotCount: 2, slotName: "4K" })).label).toBe("「4K」命中第 1 档");
|
||||
});
|
||||
|
||||
it("与库里版本比较", () => {
|
||||
expect(vsLibraryLabel(verdict({ vsLibrary: { cmp: "higher", steps: 1 } }))).toBe("比库里版本高 1 档");
|
||||
expect(vsLibraryLabel(verdict({ vsLibrary: { cmp: "higher", steps: null } }))).toBe("比库里版本好");
|
||||
expect(vsLibraryLabel(verdict({ vsLibrary: { cmp: "same", steps: 0 } }))).toBe("与库里版本同档");
|
||||
expect(vsLibraryLabel(verdict({ vsLibrary: { cmp: "lower", steps: 2 } }))).toBe("不如库里版本");
|
||||
expect(vsLibraryLabel(verdict())).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { LocalMediaHit, MediaKind, SearchInventoryState, SearchSiteStatus } from "@umt/contracts";
|
||||
import type {
|
||||
LocalMediaHit,
|
||||
MediaKind,
|
||||
SearchInventoryState,
|
||||
SearchRelease,
|
||||
SearchReleaseParsed,
|
||||
SearchRuleVerdict,
|
||||
SearchSiteStatus,
|
||||
} from "@umt/contracts";
|
||||
import { Compass, type LucideIcon, Zap } from "lucide-react";
|
||||
import type { StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { MEDIA_KIND } from "~/components/library/labels.ts";
|
||||
@@ -63,3 +71,157 @@ export const gbToBytes = (s: string): number | undefined => {
|
||||
const n = Number(s);
|
||||
return s.trim() && Number.isFinite(n) && n >= 0 ? Math.round(n * GB) : undefined;
|
||||
};
|
||||
|
||||
// ── 资源行(SEARCH-5:学 MP 的规格标签、促销角标、按作品分组;加上本工具的规则判定)──
|
||||
|
||||
export type SpecDim = "episode" | "resolution" | "source" | "codec" | "hdr" | "audio" | "lang" | "group";
|
||||
export interface ReleaseSpecTag {
|
||||
key: string;
|
||||
dim: SpecDim;
|
||||
label: string;
|
||||
/** 悬停提示:「分辨率:1080p」 */
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const SPEC_DIM_LABEL: Record<SpecDim, string> = {
|
||||
episode: "季集",
|
||||
resolution: "分辨率",
|
||||
source: "来源",
|
||||
codec: "编码",
|
||||
hdr: "HDR",
|
||||
audio: "音频",
|
||||
lang: "语言",
|
||||
group: "制作组",
|
||||
};
|
||||
|
||||
const SUB_LABEL: Record<string, string> = {
|
||||
zh: "中字",
|
||||
"zh-Hans": "简中",
|
||||
"zh-Hant": "繁中",
|
||||
en: "英字",
|
||||
ja: "日字",
|
||||
ko: "韩字",
|
||||
};
|
||||
const DUB_LABEL: Record<string, string> = { cmn: "国语", yue: "粤语", en: "英语配音" };
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
/**
|
||||
* 规格标签,固定顺序:季集 → 分辨率 → 来源 → 编码 → HDR → 音频 → 配音 / 字幕语言 → 制作组。
|
||||
* 都来自标题解析;同名去重,不猜。
|
||||
*/
|
||||
export function releaseSpecTags(p: SearchReleaseParsed): ReleaseSpecTag[] {
|
||||
const out: ReleaseSpecTag[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (dim: SpecDim, label: string | null | undefined) => {
|
||||
const l = label?.trim();
|
||||
if (!l || seen.has(`${dim}:${l.toLowerCase()}`)) return;
|
||||
seen.add(`${dim}:${l.toLowerCase()}`);
|
||||
out.push({ key: `${dim}:${l}`, dim, label: l, title: `${SPEC_DIM_LABEL[dim]}:${l}` });
|
||||
};
|
||||
if (p.season !== null || p.episodes) {
|
||||
const s =
|
||||
p.season !== null
|
||||
? `S${pad2(p.season)}${p.seasonEnd && p.seasonEnd !== p.season ? `-S${pad2(p.seasonEnd)}` : ""}`
|
||||
: "";
|
||||
const e = p.episodes
|
||||
? `E${pad2(p.episodes.start)}${p.episodes.end !== p.episodes.start ? `-E${pad2(p.episodes.end)}` : ""}`
|
||||
: "";
|
||||
add("episode", `${s}${e}`);
|
||||
}
|
||||
add("resolution", p.resolution);
|
||||
add("source", p.source);
|
||||
add("codec", p.videoCodec);
|
||||
for (const h of p.hdr) add("hdr", h);
|
||||
for (const a of p.audio ?? []) add("audio", a);
|
||||
for (const l of p.audioLangs ?? []) add("lang", DUB_LABEL[l] ?? l);
|
||||
for (const l of p.subtitles ?? []) add("lang", SUB_LABEL[l] ?? l);
|
||||
add("group", p.releaseGroup);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 促销标签:免费(含 2x 免费)属状态,用 success;其余折扣中性 */
|
||||
export function promoBadge(
|
||||
r: Pick<SearchRelease, "promotion" | "free">,
|
||||
): { label: string; tone: "success" | "neutral" } | null {
|
||||
const label = promoLabel(r.promotion.type);
|
||||
if (!label) return null;
|
||||
return { label, tone: r.free ? "success" : "neutral" };
|
||||
}
|
||||
|
||||
/** H&R:明确有 → warning;未知 → 中性「H&R 未知」;明确没有 → 不显示 */
|
||||
export function hrBadge(r: Pick<SearchRelease, "hr">): { label: string; tone: "warning" | "neutral" } | null {
|
||||
if (r.hr === true) return { label: "H&R", tone: "warning" };
|
||||
if (r.hr === "unknown") return { label: "H&R 未知", tone: "neutral" };
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface VerdictView {
|
||||
label: string;
|
||||
tone: "success" | "neutral";
|
||||
/** 悬停提示:档位名、不满足的逐条说明 */
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** 规则判定 → 文案:「命中第 1 档」「满足收录条件」「不满足:分辨率、来源」「无法判断:分辨率」;多个收录版本时带名字 */
|
||||
export function verdictView(v: SearchRuleVerdict): VerdictView {
|
||||
const dims = (xs: string[]) => (xs.length > 3 ? `${xs.slice(0, 3).join("、")} 等 ${xs.length} 项` : xs.join("、"));
|
||||
const text =
|
||||
v.outcome === "hit"
|
||||
? v.tier !== null
|
||||
? `命中第 ${v.tier} 档`
|
||||
: "满足收录条件"
|
||||
: v.outcome === "miss"
|
||||
? `不满足:${dims(v.failed) || "收录条件"}`
|
||||
: `无法判断:${dims(v.failed) || "缺少字段"}`;
|
||||
const label = v.slotCount > 1 ? `「${v.slotName}」${text}` : text;
|
||||
const title = [v.slotCount > 1 ? `收录版本「${v.slotName}」` : null, v.tierName, ...v.details]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
return { label, tone: v.outcome === "hit" ? "success" : "neutral", title };
|
||||
}
|
||||
|
||||
/** 与库里版本比较:「比库里版本高 1 档」「比库里版本好」「与库里版本同档」「不如库里版本」 */
|
||||
export function vsLibraryLabel(v: SearchRuleVerdict): string | null {
|
||||
const c = v.vsLibrary;
|
||||
if (!c) return null;
|
||||
if (c.cmp === "higher") return c.steps ? `比库里版本高 ${c.steps} 档` : "比库里版本好";
|
||||
if (c.cmp === "same") return "与库里版本同档";
|
||||
return "不如库里版本";
|
||||
}
|
||||
|
||||
export interface WorkGroup {
|
||||
/** 作品 ID;未关联作品的一组为 null */
|
||||
key: string | null;
|
||||
entity: SearchRelease["entity"];
|
||||
entityMatch: SearchRelease["entityMatch"];
|
||||
inventory: SearchRelease["inventory"];
|
||||
items: SearchRelease[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按作品分组(保持结果顺序,组按首次出现排;未关联作品的放最后)。
|
||||
* 只涉及一部作品(或全都未关联)时 grouped = false:作品名只在列表上方出现一次,行里不重复。
|
||||
*/
|
||||
export function groupByWork(rows: readonly SearchRelease[]): { grouped: boolean; groups: WorkGroup[] } {
|
||||
const map = new Map<string, WorkGroup>();
|
||||
let loose: WorkGroup | null = null;
|
||||
for (const r of rows) {
|
||||
if (!r.entity) {
|
||||
loose ??= { key: null, entity: null, entityMatch: null, inventory: null, items: [] };
|
||||
loose.items.push(r);
|
||||
continue;
|
||||
}
|
||||
let g = map.get(r.entity.id);
|
||||
if (!g) {
|
||||
g = { key: r.entity.id, entity: r.entity, entityMatch: r.entityMatch, inventory: r.inventory, items: [] };
|
||||
map.set(r.entity.id, g);
|
||||
}
|
||||
// 同一作品里有已识别的,组头就不标「未确认」
|
||||
if (r.entityMatch === "resolved") g.entityMatch = "resolved";
|
||||
g.inventory ??= r.inventory;
|
||||
g.items.push(r);
|
||||
}
|
||||
const groups = [...map.values(), ...(loose ? [loose] : [])];
|
||||
return { grouped: groups.length > 1, groups };
|
||||
}
|
||||
@@ -104,6 +104,35 @@ export const SearchReleaseInventory = InventoryEntry.extend({
|
||||
});
|
||||
export type SearchReleaseInventory = z.infer<typeof SearchReleaseInventory>;
|
||||
|
||||
/**
|
||||
* 规则判定(只在关联到本地作品、且作品的收录版本配置了条件或规则组时给出):
|
||||
* 按收录版本的评估口径(硬条件 + 规则组链 + 阶梯)判断这条资源,多个收录版本时取最好的一个。
|
||||
*/
|
||||
export const SearchRuleVerdict = z.object({
|
||||
slotId: Id,
|
||||
slotName: z.string(),
|
||||
/** 作品共有几个收录版本参与判定;>1 时界面写出收录版本名 */
|
||||
slotCount: z.number().int().positive(),
|
||||
/** hit = 满足;miss = 不满足;unknown = 标题缺少判定需要的字段 */
|
||||
outcome: z.enum(["hit", "miss", "unknown"]),
|
||||
/** 命中的档位(1 起);没有分档或未命中为 null */
|
||||
tier: z.number().int().positive().nullable(),
|
||||
/** 档位名(规则组名·第 N 层 / 阶梯层名) */
|
||||
tierName: z.string().nullable(),
|
||||
/** 不满足(或缺字段)的维度:分辨率 / 来源 / 编码 / HDR / 字幕 / 体积 / 制作组 / 关键字 / 规则组…,去重 */
|
||||
failed: z.array(z.string()),
|
||||
/** 逐条说明(悬停提示) */
|
||||
details: z.array(z.string()),
|
||||
/**
|
||||
* 与库里该收录版本当前版本相比:higher / same / lower;steps = 相差几档(按档位比较时给出)。
|
||||
* 没有当前版本或无法比较时为 null。
|
||||
*/
|
||||
vsLibrary: z
|
||||
.object({ cmp: z.enum(["higher", "same", "lower"]), steps: z.number().int().nonnegative().nullable() })
|
||||
.nullable(),
|
||||
});
|
||||
export type SearchRuleVerdict = z.infer<typeof SearchRuleVerdict>;
|
||||
|
||||
export const SearchRelease = z.object({
|
||||
/** `${siteId}:${siteReleaseId}`,前端列表 key */
|
||||
key: z.string(),
|
||||
@@ -140,6 +169,8 @@ export const SearchRelease = z.object({
|
||||
entity: z.object({ id: Id, title: z.string(), year: z.number().int().nullable(), kind: MediaKind }).nullable(),
|
||||
/** 库存叠加;candidateReasons 只保留针对本条资源的评估 */
|
||||
inventory: SearchReleaseInventory.nullable(),
|
||||
/** 与本地作品收录规则的关系;没关联作品或没有规则时为 null */
|
||||
ruleVerdict: SearchRuleVerdict.nullable(),
|
||||
});
|
||||
export type SearchRelease = z.infer<typeof SearchRelease>;
|
||||
|
||||
|
||||
Reference in new issue
Block a user