Merge branch 'libv1'
Co-Authored-By: Claude Fable 5.1 <[email protected]> # Conflicts: # docs/plan/00-first-version-plan.md
This commit is contained in:
commit
3f1ff01689
19 files changed
+2053
-2
No files matched your search
@@ -0,0 +1,273 @@
|
||||
import type { DB } from "@umt/db";
|
||||
import type { Kysely } from "kysely";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { backfillVersionSpecs } from "../services/version-backfill.ts";
|
||||
import { createHarness, type Harness, pgAvailable, seedEntity, seedSlot } from "./harness.ts";
|
||||
|
||||
// LIBV-1:入库版本 API(每集多个版本、季汇总、单目标比对)与规格回填(dry-run / apply)。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const GB = 1_000_000_000;
|
||||
|
||||
const PROBE_DV = {
|
||||
status: "ok",
|
||||
durationSec: 3300,
|
||||
video: [{ codec: "hevc", width: 3840, height: 2160, pixFmt: "yuv420p10le", hdr: "DV" }],
|
||||
audio: [{ codec: "eac3", profile: "Dolby Digital Plus + Dolby Atmos", channels: 6, language: "eng" }],
|
||||
subtitles: [{ language: "chi", title: "简体" }],
|
||||
};
|
||||
|
||||
async function seedAsset(
|
||||
db: Kysely<DB>,
|
||||
a: {
|
||||
entityId: string;
|
||||
rootId: string;
|
||||
path: string;
|
||||
size: number;
|
||||
units: string[];
|
||||
mediaInfo?: object;
|
||||
delivery?: object;
|
||||
},
|
||||
) {
|
||||
const asset = await db
|
||||
.insertInto("media_asset")
|
||||
.values({
|
||||
media_entity_id: a.entityId,
|
||||
kind: "video",
|
||||
size_bytes: a.size,
|
||||
media_info: JSON.stringify(a.mediaInfo ?? {}),
|
||||
delivery: JSON.stringify(a.delivery ?? {}),
|
||||
status: "active",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await db
|
||||
.insertInto("asset_location")
|
||||
.values({
|
||||
media_asset_id: asset.id,
|
||||
location_kind: "library",
|
||||
library_root_id: a.rootId,
|
||||
relative_path: a.path,
|
||||
status: "available",
|
||||
is_primary: true,
|
||||
size_bytes: a.size,
|
||||
})
|
||||
.execute();
|
||||
for (const u of a.units)
|
||||
await db.insertInto("asset_unit_coverage").values({ media_asset_id: asset.id, content_unit_id: u }).execute();
|
||||
return asset.id;
|
||||
}
|
||||
|
||||
describe.skipIf(!hasPg)("LIBV-1 入库版本", () => {
|
||||
let h: Harness;
|
||||
let rootId: string;
|
||||
let series: string;
|
||||
let movie: string;
|
||||
let bare: string;
|
||||
const eps: string[] = [];
|
||||
const ids: Record<string, string> = {};
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createHarness("libv1");
|
||||
rootId = (
|
||||
await h.db
|
||||
.insertInto("library_root")
|
||||
.values({ name: "剧集", media_kind: "mixed", local_path: "/media/tv/", management_mode: "managed" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
series = await seedEntity(h.db, { kind: "series", title: "人生切割术", year: 2022 });
|
||||
await h.db
|
||||
.insertInto("content_unit")
|
||||
.values({ media_entity_id: series, kind: "season", season_number: 1, status: "confirmed", episode_total: 3 })
|
||||
.execute();
|
||||
for (const n of [1, 2, 3])
|
||||
eps.push(
|
||||
(
|
||||
await h.db
|
||||
.insertInto("content_unit")
|
||||
.values({
|
||||
media_entity_id: series,
|
||||
kind: "episode",
|
||||
season_number: 1,
|
||||
episode_number: n,
|
||||
status: "confirmed",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id,
|
||||
);
|
||||
const dir = "人生切割术 (2022)/Season 1";
|
||||
ids.e1dv = await seedAsset(h.db, {
|
||||
entityId: series,
|
||||
rootId,
|
||||
path: `${dir}/Severance.S01E01.2160p.ATVP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv`,
|
||||
size: 14.2 * GB,
|
||||
units: [eps[0]!],
|
||||
mediaInfo: { probe: PROBE_DV, _umt: { quality: { resolution: "2160p", source: "WEB-DL" } } },
|
||||
delivery: { resolution: "2160p", sourceType: "WEB-DL" },
|
||||
});
|
||||
ids.e1bd = await seedAsset(h.db, {
|
||||
entityId: series,
|
||||
rootId,
|
||||
path: `${dir}/Severance.S01E01.1080p.BluRay.x264.DTS-HD.MA.5.1-CHD.mkv`,
|
||||
size: 8 * GB,
|
||||
units: [eps[0]!],
|
||||
});
|
||||
ids.e2 = await seedAsset(h.db, {
|
||||
entityId: series,
|
||||
rootId,
|
||||
path: `${dir}/Severance.S01E02.2160p.ATVP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv`,
|
||||
size: 13 * GB,
|
||||
units: [eps[1]!],
|
||||
});
|
||||
// 附属外挂字幕
|
||||
const sub = await h.db
|
||||
.insertInto("media_asset")
|
||||
.values({ media_entity_id: series, kind: "subtitle", parent_asset_id: ids.e2, status: "active" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("asset_location")
|
||||
.values({
|
||||
media_asset_id: sub.id,
|
||||
library_root_id: rootId,
|
||||
relative_path: `${dir}/Severance.S01E02.chs.srt`,
|
||||
status: "available",
|
||||
})
|
||||
.execute();
|
||||
await seedSlot(h.db, { entityId: series, name: "4K", hard: { resolutions: ["2160p"] } });
|
||||
|
||||
movie = await seedEntity(h.db, { kind: "movie", title: "奥本海默", year: 2023 });
|
||||
ids.movie = await seedAsset(h.db, {
|
||||
entityId: movie,
|
||||
rootId,
|
||||
path: "奥本海默 (2023)/Oppenheimer.2023.2160p.UHD.BluRay.REMUX.HDR10.HEVC.TrueHD.7.1.Atmos-FraMeSToR.mkv",
|
||||
size: 80 * GB,
|
||||
units: [],
|
||||
});
|
||||
bare = await seedEntity(h.db, { kind: "movie", title: "没有版本目标", year: 2020 });
|
||||
await seedAsset(h.db, {
|
||||
entityId: bare,
|
||||
rootId,
|
||||
path: "x/Bare.2020.1080p.WEB-DL.H264-GRP.mkv",
|
||||
size: GB,
|
||||
units: [],
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("剧集:一集两个版本、季汇总、按版本目标逐集比对", async () => {
|
||||
const r = await h.api("GET", `/library/${series}/versions`);
|
||||
expect(r.status).toBe(200);
|
||||
const v = r.body.data;
|
||||
expect(v.kind).toBe("series");
|
||||
expect(v.targets).toEqual([{ slotId: expect.any(String), name: "4K" }]);
|
||||
expect(v.summary.versionCount).toBe(3);
|
||||
const s1 = v.seasons[0];
|
||||
expect(s1).toMatchObject({ seasonNumber: 1, episodeTotal: 3, episodesWithVersion: 2 });
|
||||
expect(s1.summary.sizeBytes).toBe(35.2 * GB);
|
||||
expect(s1.summary.specs[0]).toEqual({ resolution: "2160p", source: "WEB-DL", hdr: ["DV"], count: 2 });
|
||||
expect(s1.matchCounts).toEqual({ satisfied: 2, missing: 1, upgradeable: 0 });
|
||||
|
||||
const [e1, e2, e3] = s1.episodes;
|
||||
expect(e1.versions.map((x: { assetId: string }) => x.assetId).sort()).toEqual([ids.e1dv, ids.e1bd].sort());
|
||||
expect(e1.matches).toEqual([{ slotId: v.targets[0].slotId, state: "satisfied", assetId: ids.e1dv, reasons: [] }]);
|
||||
const dv = e1.versions.find((x: { assetId: string }) => x.assetId === ids.e1dv);
|
||||
expect(dv).toMatchObject({
|
||||
resolution: "2160p",
|
||||
source: "WEB-DL",
|
||||
videoCodec: "H.265",
|
||||
hdr: ["DV", "HDR10"],
|
||||
releaseGroup: "FLUX",
|
||||
audio: [{ codec: "DDP Atmos", channels: "5.1", language: "en" }],
|
||||
subtitleLanguages: ["zh-Hans"],
|
||||
durationSec: 3300,
|
||||
sizeBytes: 14.2 * GB,
|
||||
specFrom: ["name", "probe"],
|
||||
specStored: false,
|
||||
locationStatus: "available",
|
||||
unitIds: [eps[0]],
|
||||
});
|
||||
expect(dv.absolutePath).toBe(
|
||||
"/media/tv/人生切割术 (2022)/Season 1/Severance.S01E01.2160p.ATVP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv",
|
||||
);
|
||||
expect(dv.probe.status).toBe("ok");
|
||||
expect(e2.versions[0].subtitleLanguages).toEqual(["zh-Hans"]);
|
||||
expect(e3).toMatchObject({ episodeNumber: 3, versions: [] });
|
||||
expect(e3.matches[0]).toMatchObject({ state: "missing", assetId: null });
|
||||
expect(v.unplaced).toEqual([]);
|
||||
});
|
||||
|
||||
it("指定一个作品没有的版本目标 = 不比对;电影给出版本与比对", async () => {
|
||||
const r = await h.api("GET", `/library/${series}/versions?slotId=00000000-0000-4000-8000-000000000000`);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.targets).toEqual([]);
|
||||
expect(r.body.data.seasons[0].episodes[0].matches).toBeUndefined();
|
||||
expect(r.body.data.seasons[0].matchCounts).toBeUndefined();
|
||||
|
||||
const m = await h.api("GET", `/library/${bare}/versions`);
|
||||
expect(m.body.data.targets).toEqual([]);
|
||||
expect(m.body.data.movie.versions).toHaveLength(1);
|
||||
expect(m.body.data.movie.matches).toBeUndefined();
|
||||
|
||||
await seedSlot(h.db, { entityId: movie, name: "1080p", hard: { resolutions: ["1080p"] } });
|
||||
const mv = await h.api("GET", `/library/${movie}/versions`);
|
||||
expect(mv.body.data.movie.versions[0]).toMatchObject({ source: "REMUX", resolution: "2160p", edition: null });
|
||||
expect(mv.body.data.movie.matches[0]).toMatchObject({ state: "upgradeable", assetId: ids.movie });
|
||||
|
||||
const nf = await h.api("GET", "/library/00000000-0000-4000-8000-000000000000/versions");
|
||||
expect(nf.status).toBe(404);
|
||||
});
|
||||
|
||||
it("回填:dry-run 只出清单不写库;apply 后再跑没有待更新", async () => {
|
||||
const before = await h.db
|
||||
.selectFrom("media_asset")
|
||||
.select(["id", "delivery"])
|
||||
.where("kind", "=", "video")
|
||||
.execute();
|
||||
const dry = await backfillVersionSpecs(h.db, { entityId: series });
|
||||
expect(dry).toMatchObject({ apply: false, scanned: 3, upToDate: 0, changed: 3, probed: 0, noLocation: 0 });
|
||||
const item = dry.items.find((i) => i.assetId === ids.e1dv)!;
|
||||
expect(item).toMatchObject({ reason: "no_spec", entityTitle: "人生切割术", before: "2160p · WEB-DL" });
|
||||
expect(item.after).toBe("2160p · WEB-DL · H.265 · DV/HDR10 · DDP Atmos · 字幕 zh-Hans · FLUX");
|
||||
const after = await h.db.selectFrom("media_asset").select(["id", "delivery"]).where("kind", "=", "video").execute();
|
||||
expect(after).toEqual(before);
|
||||
|
||||
// 可选探测:只对缺探测结果的本地文件调用端口
|
||||
const probed: string[] = [];
|
||||
const withProbe = await backfillVersionSpecs(h.db, {
|
||||
entityId: series,
|
||||
probe: async (p) => {
|
||||
probed.push(p);
|
||||
return { status: "unknown" };
|
||||
},
|
||||
});
|
||||
expect(withProbe.probed).toBe(0);
|
||||
expect(probed).toHaveLength(2);
|
||||
expect(probed.every((p) => p.startsWith("/media/tv/人生切割术 (2022)/Season 1/"))).toBe(true);
|
||||
|
||||
const applied = await backfillVersionSpecs(h.db, { apply: true });
|
||||
expect(applied.changed).toBe(5);
|
||||
const row = await h.db
|
||||
.selectFrom("media_asset")
|
||||
.select("delivery")
|
||||
.where("id", "=", ids.e1dv!)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(row.delivery).toMatchObject({
|
||||
specVersion: 1,
|
||||
sourceType: "WEB-DL",
|
||||
releaseGroup: "FLUX",
|
||||
durationSec: 3300,
|
||||
});
|
||||
const again = await backfillVersionSpecs(h.db, {});
|
||||
expect(again).toMatchObject({ changed: 0, upToDate: 5 });
|
||||
|
||||
const r = await h.api("GET", `/library/${series}/versions`);
|
||||
const dv = r.body.data.seasons[0].episodes[0].versions.find((x: { assetId: string }) => x.assetId === ids.e1dv);
|
||||
expect(dv).toMatchObject({ specStored: true, hdr: ["DV", "HDR10"], releaseGroup: "FLUX" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { IdParam, LibraryVersions, LibraryVersionsQuery } from "@umt/contracts";
|
||||
import type { ModuleApp } from "../../types.ts";
|
||||
import type { AoDeps } from "../services/common.ts";
|
||||
import { loadLibraryVersions } from "../services/library-versions.ts";
|
||||
import { notFound } from "../util.ts";
|
||||
import { mount } from "./helpers.ts";
|
||||
|
||||
// LIBV-1:作品的入库版本(每季每集的本地文件版本、季 / 作品汇总、与版本目标的比对)。
|
||||
|
||||
export function registerLibraryVersionRoutes(app: ModuleApp, d: AoDeps) {
|
||||
mount(
|
||||
app,
|
||||
{
|
||||
method: "get",
|
||||
path: "/library/{id}/versions",
|
||||
scope: "read",
|
||||
summary: "作品的入库版本:每季每集的本地文件版本与规格、季 / 作品汇总、与版本目标的比对",
|
||||
params: IdParam,
|
||||
query: LibraryVersionsQuery,
|
||||
response: LibraryVersions,
|
||||
},
|
||||
async (_c, { params, query }) => {
|
||||
const p = params as { id: string };
|
||||
const q = query as LibraryVersionsQuery;
|
||||
const out = await loadLibraryVersions(d.ctx.db, p.id, { slotId: q.slotId });
|
||||
if (!out) notFound("作品");
|
||||
return out!;
|
||||
},
|
||||
{ tag: "library" },
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import type { ModuleApp } from "../../types.ts";
|
||||
import { type AoDeps, CANDIDATE_ARTIFACT_TITLE } from "../services/common.ts";
|
||||
import { notFound, toApi, toApiList } from "../util.ts";
|
||||
import { mount, paginate } from "./helpers.ts";
|
||||
import { registerLibraryVersionRoutes } from "./library-versions.ts";
|
||||
|
||||
// 媒体库(列表/详情)、站点工作台库存叠加、Emby 兼容库存查询(多实例,未知 ≠ 缺失)。
|
||||
|
||||
@@ -124,6 +125,7 @@ const emptyCounts = (): SlotStatusCounts => ({
|
||||
|
||||
export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
|
||||
const db = () => d.ctx.db;
|
||||
registerLibraryVersionRoutes(app, d);
|
||||
|
||||
mount(
|
||||
app,
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import { basename } from "node:path";
|
||||
import type {
|
||||
AcquisitionSlot,
|
||||
AssetVersion,
|
||||
EpisodeVersions,
|
||||
LibraryVersions,
|
||||
MatchCounts,
|
||||
SeasonVersions,
|
||||
UnitMatch,
|
||||
} from "@umt/contracts";
|
||||
import type { AcquisitionSlotRow } from "@umt/db";
|
||||
import {
|
||||
deliveryOfSpec,
|
||||
type LocalVersion,
|
||||
matchVersions,
|
||||
type SlotSpec,
|
||||
sortHdr,
|
||||
specFromDelivery,
|
||||
summarizeVersions,
|
||||
type VersionSpec,
|
||||
versionSpecOf,
|
||||
} from "@umt/domain";
|
||||
import { slotSpecFromRow } from "@umt/mapping";
|
||||
import { toApi } from "../util.ts";
|
||||
import type { Exec } from "./common.ts";
|
||||
import { slotOrder } from "./coverage.ts";
|
||||
import { loadRuleCatalog, resolveRuleChain } from "./rules.ts";
|
||||
|
||||
// LIBV-1 入库版本:作品下的视频资产按覆盖的内容单元归到季 / 集,每个资产一个版本。
|
||||
// 规格优先读 delivery(已按当前口径写回),否则按 文件名 + media_info.probe 现算(不访问文件系统、不请求站点)。
|
||||
// 比对:单个版本目标(slotId 指定或作品的主版本目标);多目标时 matches 按目标逐条追加。
|
||||
|
||||
const VIDEO_KINDS = ["video", "bluray_dir"] as const;
|
||||
const SHOWN_STATUS = ["active", "missing"] as const;
|
||||
|
||||
interface AssetRow {
|
||||
id: string;
|
||||
status: string;
|
||||
size_bytes: number | string | null;
|
||||
media_info: unknown;
|
||||
delivery: unknown;
|
||||
content_edition: string | null;
|
||||
source_release_id: string | null;
|
||||
download_task_id: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
interface LocRow {
|
||||
media_asset_id: string;
|
||||
library_root_id: string | null;
|
||||
relative_path: string;
|
||||
status: string;
|
||||
is_primary: boolean;
|
||||
location_kind: string;
|
||||
size_bytes: number | string | null;
|
||||
}
|
||||
|
||||
const num = (v: number | string | null | undefined) => (v === null || v === undefined ? null : Number(v));
|
||||
|
||||
function probeOf(mediaInfo: unknown): Record<string, unknown> | null {
|
||||
const p = (mediaInfo as { probe?: unknown } | null)?.probe;
|
||||
return p && typeof p === "object" ? (p as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
type Quality = {
|
||||
resolution?: string;
|
||||
source?: string;
|
||||
isEncode?: boolean;
|
||||
hdr?: string[];
|
||||
videoCodec?: string;
|
||||
cut?: string;
|
||||
releaseGroup?: { name?: string } | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 资产规格:库内文件名 + ffprobe;文件名里缺的来源 / 制作组 / 分辨率等,用入库时记下的资源解析结果
|
||||
* (media_info._umt.quality,来自种子标题)补齐。
|
||||
*/
|
||||
export function specForAsset(path: string, mediaInfo: unknown, sidecars: readonly string[] = []): VersionSpec {
|
||||
const probe = probeOf(mediaInfo);
|
||||
const spec = versionSpecOf({ path, probe: probe as never, sidecarSubtitles: sidecars });
|
||||
const q = ((mediaInfo as { _umt?: { quality?: Quality } } | null)?._umt?.quality ?? {}) as Quality;
|
||||
let used = false;
|
||||
if (!spec.resolution && q.resolution) {
|
||||
spec.resolution = q.resolution as VersionSpec["resolution"];
|
||||
used = true;
|
||||
}
|
||||
if (!spec.source && q.source) {
|
||||
spec.source = q.source as VersionSpec["source"];
|
||||
spec.isEncode = !!q.isEncode;
|
||||
used = true;
|
||||
}
|
||||
if (!spec.videoCodec && q.videoCodec) {
|
||||
spec.videoCodec = q.videoCodec as VersionSpec["videoCodec"];
|
||||
used = true;
|
||||
}
|
||||
if (!spec.hdr.length && q.hdr?.length) {
|
||||
spec.hdr = sortHdr(q.hdr);
|
||||
used = true;
|
||||
}
|
||||
const group = typeof q.releaseGroup === "string" ? q.releaseGroup : q.releaseGroup?.name;
|
||||
if (!spec.releaseGroup && group) {
|
||||
spec.releaseGroup = group;
|
||||
used = true;
|
||||
}
|
||||
if (!spec.edition && q.cut) spec.edition = q.cut as VersionSpec["edition"];
|
||||
if (used && !spec.specFrom.includes("name")) spec.specFrom = ["name", ...spec.specFrom];
|
||||
return spec;
|
||||
}
|
||||
|
||||
/** 已写回且不落后于探测结果的规格(写回后才探测到的,按未写回处理) */
|
||||
export function storedSpec(delivery: unknown, mediaInfo: unknown): VersionSpec | null {
|
||||
const s = specFromDelivery(delivery);
|
||||
if (!s) return null;
|
||||
if (!s.specFrom.includes("probe") && (probeOf(mediaInfo) as { status?: string } | null)?.status === "ok") return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 入库时写 media_asset.delivery 的规格(整理执行与回填共用) */
|
||||
export const deliveryForAsset = (path: string, mediaInfo: unknown, sidecars: readonly string[] = []) =>
|
||||
deliveryOfSpec(specForAsset(path, mediaInfo, sidecars));
|
||||
|
||||
/** 选展示用的位置:未移除 → 库内 → 主位置 → 可用 */
|
||||
function pickLocation(locs: readonly LocRow[]): LocRow | null {
|
||||
const score = (l: LocRow) =>
|
||||
(l.status !== "removed" ? 8 : 0) +
|
||||
(l.location_kind === "library" ? 4 : 0) +
|
||||
(l.is_primary ? 2 : 0) +
|
||||
(l.status === "available" ? 1 : 0);
|
||||
return [...locs].sort((a, b) => score(b) - score(a))[0] ?? null;
|
||||
}
|
||||
|
||||
/** 资产 → 版本(规格 + 位置);sidecars 为外挂字幕文件名 */
|
||||
export function assetVersionOf(
|
||||
a: AssetRow,
|
||||
loc: LocRow | null,
|
||||
rootPath: string | null,
|
||||
unitIds: string[],
|
||||
sidecars: string[],
|
||||
): { view: AssetVersion; spec: VersionSpec } {
|
||||
const stored = storedSpec(a.delivery, a.media_info);
|
||||
const probe = probeOf(a.media_info);
|
||||
const path = loc?.relative_path ?? "";
|
||||
const spec = stored ?? specForAsset(path, a.media_info, sidecars);
|
||||
const size = num(a.size_bytes) ?? num(loc?.size_bytes);
|
||||
const edition = a.content_edition ?? spec.edition ?? null;
|
||||
return {
|
||||
spec,
|
||||
view: {
|
||||
assetId: a.id,
|
||||
status: a.status as AssetVersion["status"],
|
||||
resolution: spec.resolution ?? null,
|
||||
source: spec.source ?? null,
|
||||
isEncode: !!spec.isEncode,
|
||||
videoCodec: spec.videoCodec ?? null,
|
||||
bitDepth: spec.bitDepth ?? null,
|
||||
hdr: spec.hdr,
|
||||
audio: spec.audio,
|
||||
subtitleLanguages: spec.subtitleLanguages,
|
||||
releaseGroup: spec.releaseGroup ?? null,
|
||||
edition,
|
||||
sizeBytes: size,
|
||||
durationSec: spec.durationSec ?? null,
|
||||
fileName: path ? basename(path) : a.id,
|
||||
relativePath: loc?.relative_path ?? null,
|
||||
absolutePath: loc && rootPath ? `${rootPath.replace(/\/+$/, "")}/${loc.relative_path}` : null,
|
||||
libraryRootId: loc?.library_root_id ?? null,
|
||||
locationStatus: (loc?.status as AssetVersion["locationStatus"]) ?? null,
|
||||
organizedAt: a.created_at.toISOString(),
|
||||
downloadTaskId: a.download_task_id,
|
||||
sourceReleaseId: a.source_release_id,
|
||||
specFrom: spec.specFrom,
|
||||
specStored: !!stored,
|
||||
probe,
|
||||
unitIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 版本目标规格:目标自身条件 + 规则组链(与收录评估同一口径,站点相关条件在比对时剔除) */
|
||||
async function targetSpec(exec: Exec, slot: AcquisitionSlotRow, entityKind: string): Promise<SlotSpec> {
|
||||
const spec = slotSpecFromRow(toApi<AcquisitionSlot>(slot));
|
||||
const cat = await loadRuleCatalog(exec);
|
||||
const chain = resolveRuleChain(cat, {
|
||||
kind: "acquisition",
|
||||
slotRuleGroupIds: (slot.rule_group_ids ?? []) as string[],
|
||||
upgrade: !!spec.upgrade?.enabled,
|
||||
mediaKind: entityKind === "movie" ? "movie" : entityKind === "variety" ? "variety" : "series",
|
||||
entityId: slot.media_entity_id,
|
||||
});
|
||||
if (chain.groups.length) spec.rules = { groups: chain.groups, atoms: chain.atoms };
|
||||
return spec;
|
||||
}
|
||||
|
||||
const zeroCounts = (): MatchCounts => ({ satisfied: 0, missing: 0, upgradeable: 0 });
|
||||
|
||||
export async function loadLibraryVersions(
|
||||
exec: Exec,
|
||||
entityId: string,
|
||||
opts: { slotId?: string | undefined } = {},
|
||||
): Promise<LibraryVersions | null> {
|
||||
const entity = await exec
|
||||
.selectFrom("media_entity")
|
||||
.select(["id", "kind"])
|
||||
.where("id", "=", entityId)
|
||||
.executeTakeFirst();
|
||||
if (!entity) return null;
|
||||
const assets = (await exec
|
||||
.selectFrom("media_asset")
|
||||
.select([
|
||||
"id",
|
||||
"status",
|
||||
"size_bytes",
|
||||
"media_info",
|
||||
"delivery",
|
||||
"content_edition",
|
||||
"source_release_id",
|
||||
"download_task_id",
|
||||
"created_at",
|
||||
])
|
||||
.where("media_entity_id", "=", entityId)
|
||||
.where("kind", "in", [...VIDEO_KINDS])
|
||||
.where("status", "in", [...SHOWN_STATUS])
|
||||
.orderBy("created_at")
|
||||
.execute()) as AssetRow[];
|
||||
const ids = assets.map((a) => a.id);
|
||||
const [locs, cov, sideRows, roots, units, slots] = await Promise.all([
|
||||
ids.length
|
||||
? exec
|
||||
.selectFrom("asset_location")
|
||||
.select([
|
||||
"media_asset_id",
|
||||
"library_root_id",
|
||||
"relative_path",
|
||||
"status",
|
||||
"is_primary",
|
||||
"location_kind",
|
||||
"size_bytes",
|
||||
])
|
||||
.where("media_asset_id", "in", ids)
|
||||
.execute()
|
||||
: Promise.resolve([] as LocRow[]),
|
||||
ids.length
|
||||
? exec
|
||||
.selectFrom("asset_unit_coverage")
|
||||
.select(["media_asset_id", "content_unit_id"])
|
||||
.where("media_asset_id", "in", ids)
|
||||
.execute()
|
||||
: Promise.resolve([]),
|
||||
ids.length
|
||||
? exec
|
||||
.selectFrom("media_asset as s")
|
||||
.innerJoin("asset_location as l", "l.media_asset_id", "s.id")
|
||||
.select(["s.parent_asset_id", "l.relative_path"])
|
||||
.where("s.parent_asset_id", "in", ids)
|
||||
.where("s.kind", "=", "subtitle")
|
||||
.where("l.status", "!=", "removed")
|
||||
.execute()
|
||||
: Promise.resolve([]),
|
||||
exec.selectFrom("library_root").select(["id", "local_path"]).execute(),
|
||||
exec
|
||||
.selectFrom("content_unit")
|
||||
.select(["id", "kind", "season_number", "episode_number", "segment", "status", "episode_total", "created_at"])
|
||||
.where("media_entity_id", "=", entityId)
|
||||
.where("status", "!=", "retired")
|
||||
.where("kind", "in", ["season", "episode"])
|
||||
.execute(),
|
||||
exec.selectFrom("acquisition_slot").selectAll().where("media_entity_id", "=", entityId).execute(),
|
||||
]);
|
||||
|
||||
const rootPath = new Map(roots.map((r) => [r.id, r.local_path]));
|
||||
const locsOf = new Map<string, LocRow[]>();
|
||||
for (const l of locs as LocRow[]) locsOf.set(l.media_asset_id, [...(locsOf.get(l.media_asset_id) ?? []), l]);
|
||||
const unitsOfAsset = new Map<string, string[]>();
|
||||
for (const c of cov)
|
||||
unitsOfAsset.set(c.media_asset_id, [...(unitsOfAsset.get(c.media_asset_id) ?? []), c.content_unit_id]);
|
||||
const sidecarsOf = new Map<string, string[]>();
|
||||
for (const s of sideRows)
|
||||
if (s.parent_asset_id)
|
||||
sidecarsOf.set(s.parent_asset_id, [...(sidecarsOf.get(s.parent_asset_id) ?? []), basename(s.relative_path)]);
|
||||
|
||||
const versions = assets.map((a) => {
|
||||
const loc = pickLocation(locsOf.get(a.id) ?? []);
|
||||
return assetVersionOf(
|
||||
a,
|
||||
loc,
|
||||
loc?.library_root_id ? (rootPath.get(loc.library_root_id) ?? null) : null,
|
||||
unitsOfAsset.get(a.id) ?? [],
|
||||
sidecarsOf.get(a.id) ?? [],
|
||||
);
|
||||
});
|
||||
const summary = summarizeVersions(versions.map((v) => ({ spec: v.spec, sizeBytes: v.view.sizeBytes })));
|
||||
|
||||
// 版本目标:指定的,或主版本目标(position → created_at → id 第一个)
|
||||
const slot = opts.slotId ? slots.find((s) => s.id === opts.slotId) : [...slots].sort(slotOrder)[0];
|
||||
const spec = slot ? await targetSpec(exec, slot, entity.kind) : null;
|
||||
const targets = slot ? [{ slotId: slot.id, name: slot.name }] : [];
|
||||
const local = (v: (typeof versions)[number]): LocalVersion => ({
|
||||
assetId: v.view.assetId,
|
||||
path: v.view.relativePath ?? v.view.fileName,
|
||||
spec: v.spec,
|
||||
sizeBytes: v.view.sizeBytes,
|
||||
unitCount: Math.max(1, v.view.unitIds.length),
|
||||
});
|
||||
|
||||
if (entity.kind === "movie") {
|
||||
const matches: UnitMatch[] | undefined = spec
|
||||
? [matchVersions(spec, versions.map(local), { isEpisode: false })]
|
||||
: undefined;
|
||||
return {
|
||||
entityId,
|
||||
kind: entity.kind,
|
||||
targets,
|
||||
summary,
|
||||
seasons: [],
|
||||
movie: { versions: versions.map((v) => v.view), ...(matches ? { matches } : {}) },
|
||||
unplaced: [],
|
||||
};
|
||||
}
|
||||
|
||||
// 剧集:集单元按 (季, 集, 分段) 去重合并(重复的临时单元并到同一行)
|
||||
const byUnit = new Map<string, typeof versions>();
|
||||
for (const v of versions) for (const u of v.view.unitIds) byUnit.set(u, [...(byUnit.get(u) ?? []), v]);
|
||||
const seasonUnits = units.filter((u) => u.kind === "season" && u.season_number !== null);
|
||||
const epUnits = units
|
||||
.filter((u) => u.kind === "episode" && u.season_number !== null && u.episode_number !== null)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.status === "confirmed" ? 0 : 1) - (b.status === "confirmed" ? 0 : 1) ||
|
||||
a.created_at.getTime() - b.created_at.getTime(),
|
||||
);
|
||||
const rows = new Map<
|
||||
string,
|
||||
{ unitId: string; season: number; episode: number; segment: string; list: typeof versions }
|
||||
>();
|
||||
const placed = new Set<string>();
|
||||
for (const u of epUnits) {
|
||||
const key = `${u.season_number}:${u.episode_number}:${u.segment}`;
|
||||
const row = rows.get(key) ?? {
|
||||
unitId: u.id,
|
||||
season: u.season_number!,
|
||||
episode: u.episode_number!,
|
||||
segment: u.segment,
|
||||
list: [],
|
||||
};
|
||||
for (const v of byUnit.get(u.id) ?? []) {
|
||||
if (!row.list.includes(v)) row.list.push(v);
|
||||
placed.add(v.view.assetId);
|
||||
}
|
||||
rows.set(key, row);
|
||||
}
|
||||
const seasonNums = [
|
||||
...new Set([...seasonUnits.map((u) => u.season_number!), ...[...rows.values()].map((r) => r.season)]),
|
||||
].sort((a, b) => (a || 999) - (b || 999));
|
||||
const seasons: SeasonVersions[] = seasonNums.map((n) => {
|
||||
const su = seasonUnits.find((u) => u.season_number === n);
|
||||
const eps = [...rows.values()].filter((r) => r.season === n).sort((a, b) => a.episode - b.episode);
|
||||
const counts = zeroCounts();
|
||||
const episodes: EpisodeVersions[] = eps.map((r) => {
|
||||
const m = spec ? matchVersions(spec, r.list.map(local), { isEpisode: true }) : null;
|
||||
if (m) counts[m.state]++;
|
||||
return {
|
||||
unitId: r.unitId,
|
||||
episodeNumber: r.episode,
|
||||
segment: r.segment === "full" || r.segment === "unknown" ? null : r.segment,
|
||||
versions: r.list.map((v) => v.view),
|
||||
...(m ? { matches: [m] } : {}),
|
||||
};
|
||||
});
|
||||
const seasonVersions = [...new Set(eps.flatMap((r) => r.list))];
|
||||
return {
|
||||
seasonNumber: n,
|
||||
unitId: su?.id ?? null,
|
||||
episodeTotal: su?.episode_total ?? (eps.length || null),
|
||||
episodesWithVersion: new Set(eps.filter((r) => r.list.length > 0).map((r) => r.episode)).size,
|
||||
summary: summarizeVersions(seasonVersions.map((v) => ({ spec: v.spec, sizeBytes: v.view.sizeBytes }))),
|
||||
episodes,
|
||||
...(spec ? { matchCounts: counts } : {}),
|
||||
};
|
||||
});
|
||||
return {
|
||||
entityId,
|
||||
kind: entity.kind,
|
||||
targets,
|
||||
summary,
|
||||
seasons,
|
||||
movie: null,
|
||||
unplaced: versions.filter((v) => !placed.has(v.view.assetId)).map((v) => v.view),
|
||||
};
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import { errMessage, hashJson, jsonb, toApi } from "../util.ts";
|
||||
import { publishedAcquisitionPolicies, unitKeysOf } from "./acquisition.ts";
|
||||
import { type AoDeps, type Exec, hostPathOf, loadInstances, upsertDecision } from "./common.ts";
|
||||
import { applySlotEvent } from "./downloads.ts";
|
||||
import { deliveryForAsset } from "./library-versions.ts";
|
||||
import { effectiveNaming, ensureNamingVersion, loadNamingEntity, namingByVersion } from "./naming.ts";
|
||||
import { PathGuardError } from "./organize-guard.ts";
|
||||
import { type FrozenTransfer, finalizeTransfer, planTransfer, type TransferSpec } from "./organize-transfer.ts";
|
||||
@@ -1440,8 +1441,11 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
...(probe ? { probe } : {}),
|
||||
_umt: { quality: fx?.quality ?? {}, unitKeys: fx?.unitKeys ?? [] },
|
||||
}),
|
||||
// LIBV-1:库内文件名 + 探测 + 资源解析合成版本规格
|
||||
delivery: jsonb(
|
||||
fx
|
||||
isMain
|
||||
? deliveryForAsset(f.target_path, { ...(probe ? { probe } : {}), _umt: { quality: fx?.quality ?? {} } })
|
||||
: fx
|
||||
? {
|
||||
...(fx.quality.resolution ? { resolution: fx.quality.resolution } : {}),
|
||||
...(fx.quality.source ? { sourceType: fx.quality.source } : {}),
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { basename } from "node:path";
|
||||
import { sql } from "@umt/db";
|
||||
import type { ProbeLike } from "@umt/domain";
|
||||
import { jsonb } from "../util.ts";
|
||||
import type { Exec } from "./common.ts";
|
||||
import { deliveryForAsset, storedSpec } from "./library-versions.ts";
|
||||
|
||||
// LIBV-1 回填:已入库视频资产的版本规格写回 media_asset.delivery。
|
||||
// 来源只有 库内文件名解析 + 已有 media_info.probe + 入库时记下的资源解析;不请求站点。
|
||||
// probe 端口可选:给了才对"本机库根里存在、还没有探测结果"的文件跑 ffprobe(只读本地文件)。
|
||||
// 默认 dry-run:只返回清单;apply = true 才写库(单条 update,可重复执行)。
|
||||
|
||||
export interface BackfillItem {
|
||||
assetId: string;
|
||||
entityId: string | null;
|
||||
entityTitle: string | null;
|
||||
path: string | null;
|
||||
/** 需要写回的原因:no_spec = 旧数据没有规格口径;stale = 写回后才有探测结果;probed = 本次探测后更新 */
|
||||
reason: "no_spec" | "stale" | "probed";
|
||||
before: string;
|
||||
after: string;
|
||||
specFrom: string[];
|
||||
}
|
||||
|
||||
export interface BackfillReport {
|
||||
apply: boolean;
|
||||
scanned: number;
|
||||
upToDate: number;
|
||||
changed: number;
|
||||
probed: number;
|
||||
/** 找不到库内位置的资产(只能靠入库时的资源解析) */
|
||||
noLocation: number;
|
||||
items: BackfillItem[];
|
||||
}
|
||||
|
||||
export interface BackfillOptions {
|
||||
apply?: boolean;
|
||||
entityId?: string;
|
||||
limit?: number;
|
||||
/** 给了才探测缺探测结果的本地文件(绝对路径) */
|
||||
probe?: (absPath: string) => Promise<ProbeLike & { status: string }>;
|
||||
}
|
||||
|
||||
/** 一行可读的规格,dry-run 清单用 */
|
||||
export function specLine(d: unknown): string {
|
||||
const o = (d ?? {}) as Record<string, unknown>;
|
||||
const parts = [
|
||||
o.resolution,
|
||||
o.sourceType,
|
||||
o.videoCodec,
|
||||
Array.isArray(o.hdr) ? (o.hdr as string[]).join("/") : null,
|
||||
Array.isArray(o.audioCodecs) ? (o.audioCodecs as string[]).join("+") : null,
|
||||
Array.isArray(o.subtitles) && (o.subtitles as string[]).length
|
||||
? `字幕 ${(o.subtitles as string[]).join(",")}`
|
||||
: null,
|
||||
o.releaseGroup,
|
||||
].filter((x) => typeof x === "string" && x !== "");
|
||||
return parts.join(" · ") || "(空)";
|
||||
}
|
||||
|
||||
export async function backfillVersionSpecs(exec: Exec, opts: BackfillOptions = {}): Promise<BackfillReport> {
|
||||
const rows = await sql<{
|
||||
id: string;
|
||||
media_entity_id: string | null;
|
||||
title: string | null;
|
||||
media_info: unknown;
|
||||
delivery: unknown;
|
||||
relative_path: string | null;
|
||||
local_path: string | null;
|
||||
}>`
|
||||
select a.id, a.media_entity_id, e.title, a.media_info, a.delivery, l.relative_path, r.local_path
|
||||
from media_asset a
|
||||
left join media_entity e on e.id = a.media_entity_id
|
||||
left join lateral (
|
||||
select l.relative_path, l.library_root_id from asset_location l
|
||||
where l.media_asset_id = a.id and l.status <> 'removed'
|
||||
order by (l.location_kind = 'library') desc, l.is_primary desc, l.created_at
|
||||
limit 1
|
||||
) l on true
|
||||
left join library_root r on r.id = l.library_root_id
|
||||
where a.kind in ('video', 'bluray_dir') and a.status in ('active', 'missing')
|
||||
${opts.entityId ? sql`and a.media_entity_id = ${opts.entityId}` : sql``}
|
||||
order by a.created_at, a.id
|
||||
${opts.limit ? sql`limit ${opts.limit}` : sql``}
|
||||
`.execute(exec);
|
||||
const ids = rows.rows.map((r) => r.id);
|
||||
const side = ids.length
|
||||
? await exec
|
||||
.selectFrom("media_asset as s")
|
||||
.innerJoin("asset_location as l", "l.media_asset_id", "s.id")
|
||||
.select(["s.parent_asset_id", "l.relative_path"])
|
||||
.where("s.parent_asset_id", "in", ids)
|
||||
.where("s.kind", "=", "subtitle")
|
||||
.where("l.status", "!=", "removed")
|
||||
.execute()
|
||||
: [];
|
||||
const sidecars = new Map<string, string[]>();
|
||||
for (const s of side)
|
||||
if (s.parent_asset_id)
|
||||
sidecars.set(s.parent_asset_id, [...(sidecars.get(s.parent_asset_id) ?? []), basename(s.relative_path)]);
|
||||
|
||||
const report: BackfillReport = {
|
||||
apply: !!opts.apply,
|
||||
scanned: rows.rows.length,
|
||||
upToDate: 0,
|
||||
changed: 0,
|
||||
probed: 0,
|
||||
noLocation: 0,
|
||||
items: [],
|
||||
};
|
||||
for (const r of rows.rows) {
|
||||
if (!r.relative_path) report.noLocation++;
|
||||
let mediaInfo = r.media_info as Record<string, unknown>;
|
||||
let probedNow = false;
|
||||
const hasProbe = (mediaInfo?.probe as { status?: string } | undefined)?.status === "ok";
|
||||
if (opts.probe && !hasProbe && r.relative_path && r.local_path) {
|
||||
const abs = `${r.local_path.replace(/\/+$/, "")}/${r.relative_path}`;
|
||||
const res = await opts.probe(abs).catch(() => null);
|
||||
if (res?.status === "ok") {
|
||||
mediaInfo = { ...mediaInfo, probe: res };
|
||||
probedNow = true;
|
||||
report.probed++;
|
||||
}
|
||||
}
|
||||
const stored = storedSpec(r.delivery, mediaInfo);
|
||||
if (stored && !probedNow) {
|
||||
report.upToDate++;
|
||||
continue;
|
||||
}
|
||||
const next = deliveryForAsset(r.relative_path ?? "", mediaInfo, sidecars.get(r.id) ?? []);
|
||||
const reason: BackfillItem["reason"] = probedNow
|
||||
? "probed"
|
||||
: (r.delivery as { specVersion?: unknown } | null)?.specVersion
|
||||
? "stale"
|
||||
: "no_spec";
|
||||
report.changed++;
|
||||
report.items.push({
|
||||
assetId: r.id,
|
||||
entityId: r.media_entity_id,
|
||||
entityTitle: r.title,
|
||||
path: r.relative_path,
|
||||
reason,
|
||||
before: specLine(r.delivery),
|
||||
after: specLine(next),
|
||||
specFrom: (next.specFrom as string[]) ?? [],
|
||||
});
|
||||
if (opts.apply) {
|
||||
// 保留 delivery 里其他键(旧字段同名覆盖);探测结果一并写回 media_info.probe
|
||||
await sql`
|
||||
update media_asset
|
||||
set delivery = delivery || ${jsonb(next)}::jsonb,
|
||||
media_info = case when ${probedNow} then media_info || jsonb_build_object('probe', ${jsonb(
|
||||
mediaInfo.probe ?? null,
|
||||
)}::jsonb) else media_info end,
|
||||
updated_at = now()
|
||||
where id = ${r.id}
|
||||
`.execute(exec);
|
||||
}
|
||||
}
|
||||
return report;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { LibraryVersions } from "@umt/contracts";
|
||||
import { getParsed } from "~/api/library.ts";
|
||||
|
||||
// LIBV-1:作品的入库版本(每集的本地文件版本、季汇总、与收录版本的比对)
|
||||
|
||||
export const libraryVersionKeys = {
|
||||
versions: (id: string, slotId?: string) => ["library", "versions", id, slotId ?? null] as const,
|
||||
};
|
||||
|
||||
export function useLibraryVersions(id: string | undefined, slotId?: string) {
|
||||
return useQuery({
|
||||
queryKey: libraryVersionKeys.versions(id ?? "", slotId),
|
||||
enabled: !!id,
|
||||
queryFn: ({ signal }) =>
|
||||
getParsed(LibraryVersions, `/library/${id}/versions`, slotId ? { slotId } : undefined, signal),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import type { AssetVersion, SeasonVersions, UnitMatch, VersionSummary } from "@umt/contracts";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useLibraryVersions } from "~/api/library-versions.ts";
|
||||
import { type KvItem, KvList } from "~/components/console/kv-list.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { Panel } from "~/components/library/parts.tsx";
|
||||
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
// LIBV-1 入库版本:每集一行规格(分辨率 · 来源 · 编码 · HDR · 制作组 · 大小),多个版本可展开逐行;
|
||||
// 季头一行汇总;路径、音轨、字幕、探测原始值放进「技术信息」折叠。
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
REMUX: "Remux",
|
||||
"BluRay-Disc": "原盘",
|
||||
BluRay: "蓝光压制",
|
||||
};
|
||||
const CODEC_LABEL: Record<string, string> = { "H.265": "HEVC" };
|
||||
const LANG_LABEL: Record<string, string> = {
|
||||
"zh-Hans": "简中",
|
||||
"zh-Hant": "繁中",
|
||||
zh: "中文",
|
||||
yue: "粤语",
|
||||
en: "英语",
|
||||
ja: "日语",
|
||||
ko: "韩语",
|
||||
fr: "法语",
|
||||
de: "德语",
|
||||
es: "西语",
|
||||
};
|
||||
|
||||
export const sourceLabel = (s: string | null) => (s ? (SOURCE_LABEL[s] ?? s) : null);
|
||||
const langLabel = (l: string) => LANG_LABEL[l] ?? l;
|
||||
|
||||
/** DV 带 HDR10 兜底时只写 DV */
|
||||
export function hdrLabel(hdr: readonly string[]): string | null {
|
||||
const shown = hdr.includes("DV") ? hdr.filter((h) => h !== "HDR10") : hdr;
|
||||
return shown.length ? shown.join("/") : null;
|
||||
}
|
||||
|
||||
/** 一行规格:2160p · WEB-DL · HEVC · DV · FLUX · 13.2 GB */
|
||||
export function versionParts(v: AssetVersion): string[] {
|
||||
return [
|
||||
v.resolution,
|
||||
sourceLabel(v.source),
|
||||
v.videoCodec ? (CODEC_LABEL[v.videoCodec] ?? v.videoCodec) : null,
|
||||
hdrLabel(v.hdr),
|
||||
v.edition,
|
||||
v.releaseGroup,
|
||||
v.sizeBytes !== null ? formatBytes(v.sizeBytes, 1) : null,
|
||||
].filter((x): x is string => !!x);
|
||||
}
|
||||
|
||||
/** 季 / 作品汇总:2160p WEB-DL DV ×8 · 1080p 蓝光压制 ×2 · 共 78 GB */
|
||||
export function summaryText(s: VersionSummary): string {
|
||||
const specs = s.specs.map((g) => {
|
||||
const label = [g.resolution, sourceLabel(g.source), hdrLabel(g.hdr)].filter(Boolean).join(" ") || "规格未知";
|
||||
return s.specs.length > 1 ? `${label} ×${g.count}` : label;
|
||||
});
|
||||
const shown = specs.length > 2 ? [...specs.slice(0, 2), `等 ${specs.length} 种`] : specs;
|
||||
return [...shown, s.sizeBytes ? `共 ${formatBytes(s.sizeBytes, 1)}` : null].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
export function seasonLine(s: SeasonVersions, withMatch = true): string | null {
|
||||
if (s.summary.versionCount === 0) return null;
|
||||
const count = `${s.episodesWithVersion}/${s.episodeTotal ?? "?"} 集`;
|
||||
const up = withMatch && s.matchCounts?.upgradeable ? `${s.matchCounts.upgradeable} 集可升级` : null;
|
||||
return [count, summaryText(s.summary), up].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function audioText(v: AssetVersion): string | null {
|
||||
if (!v.audio.length) return null;
|
||||
return v.audio
|
||||
.map((a) => [a.codec, a.channels, a.language ? langLabel(a.language) : null].filter(Boolean).join(" "))
|
||||
.join(";");
|
||||
}
|
||||
|
||||
function duration(sec: number | null): string | null {
|
||||
if (!sec) return null;
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.round((sec % 3600) / 60);
|
||||
return h ? `${h} 小时 ${m} 分钟` : `${m} 分钟`;
|
||||
}
|
||||
|
||||
const SPEC_FROM: Record<string, string> = { name: "文件名", probe: "ffprobe" };
|
||||
|
||||
/** 技术信息:路径、音轨、字幕、入库时间、下载任务、探测原始值 */
|
||||
// TODO(DEAI-2):TechInfo 组件合入后改用 components/console/tech-info.tsx,和其他页面统一折叠样式与默认展开偏好
|
||||
function VersionTech({ v }: { v: AssetVersion }) {
|
||||
const items: KvItem[] = [
|
||||
{ label: "文件", value: v.absolutePath ?? v.relativePath ?? v.fileName, mono: true, wide: true },
|
||||
{ label: "音轨", value: audioText(v) },
|
||||
{ label: "字幕", value: v.subtitleLanguages.map(langLabel).join("、") || null },
|
||||
{ label: "时长", value: duration(v.durationSec) },
|
||||
{ label: "色深", value: v.bitDepth ? `${v.bitDepth} bit` : null },
|
||||
{ label: "入库时间", value: formatDateTime(v.organizedAt) },
|
||||
{
|
||||
label: "下载任务",
|
||||
value: v.downloadTaskId ? (
|
||||
<Link to={consolePath("downloads", v.downloadTaskId)} className="underline-offset-2 hover:underline">
|
||||
查看下载任务
|
||||
</Link>
|
||||
) : null,
|
||||
},
|
||||
{ label: "规格来源", value: v.specFrom.map((f) => SPEC_FROM[f] ?? f).join(" + ") || null },
|
||||
].filter((it) => it.value !== null && it.value !== undefined && it.value !== "");
|
||||
return (
|
||||
<details className="group mt-1">
|
||||
<summary className="inline-flex cursor-pointer list-none items-center gap-1 text-meta text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden">
|
||||
技术信息
|
||||
<ChevronDown className="size-3.5 transition-transform duration-micro group-open:rotate-180" aria-hidden />
|
||||
</summary>
|
||||
<div className="grid gap-3 pt-2 pb-1">
|
||||
<KvList items={items} />
|
||||
{v.probe ? (
|
||||
<details className="group/raw">
|
||||
<summary className="cursor-pointer list-none text-meta text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden">
|
||||
探测原始值
|
||||
</summary>
|
||||
<pre className="mt-1 max-h-64 overflow-auto rounded-lg bg-muted/40 p-2 font-mono text-caption break-all whitespace-pre-wrap text-foreground">
|
||||
{JSON.stringify(v.probe, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一个版本:规格一行 + 技术信息折叠 */
|
||||
export function VersionRow({ v, best, aside }: { v: AssetVersion; best?: boolean; aside?: ReactNode }) {
|
||||
const parts = versionParts(v);
|
||||
const missing = v.status === "missing" || (v.locationStatus !== null && v.locationStatus !== "available");
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="flex min-w-0 flex-wrap items-baseline gap-x-1.5 text-caption tabular-nums text-muted-foreground">
|
||||
{parts.length ? (
|
||||
parts.map((p, i) => (
|
||||
<span key={`${i}-${p}`} className={cn("whitespace-nowrap", i === 0 && "font-medium text-foreground")}>
|
||||
{i > 0 ? <span className="mr-1.5 text-muted-foreground/60">·</span> : null}
|
||||
{p}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="break-all">{v.fileName}</span>
|
||||
)}
|
||||
{best ? <span className="text-meta text-success">符合收录版本</span> : null}
|
||||
{missing ? <span className="text-meta text-warning">文件不在原位置</span> : null}
|
||||
{aside}
|
||||
</p>
|
||||
<VersionTech v={v} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一集的版本:一个时直接一行;多个时先显示达标(或第一个)版本,「N 个版本」展开逐行 */
|
||||
export function EpisodeVersionList({
|
||||
versions,
|
||||
match,
|
||||
className,
|
||||
}: {
|
||||
versions: AssetVersion[];
|
||||
match?: UnitMatch | undefined;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!versions.length) return null;
|
||||
const lead = versions.find((v) => v.assetId === match?.assetId) ?? versions[0]!;
|
||||
const upgrade = match?.state === "upgradeable" ? match : null;
|
||||
const note = upgrade ? (
|
||||
<p className="text-meta text-warning">
|
||||
可升级
|
||||
{upgrade.reasons.length ? <span className="text-muted-foreground">:{upgrade.reasons.join(";")}</span> : null}
|
||||
</p>
|
||||
) : null;
|
||||
if (versions.length === 1)
|
||||
return (
|
||||
<div className={cn("mt-1.5 space-y-0.5", className)}>
|
||||
<VersionRow v={lead} />
|
||||
{note}
|
||||
</div>
|
||||
);
|
||||
const rest = versions.filter((v) => v !== lead);
|
||||
const tagBest = match?.state === "satisfied";
|
||||
const toggle = (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="ml-1 inline-flex cursor-pointer items-center gap-0.5 rounded-md text-meta text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
>
|
||||
{versions.length} 个版本
|
||||
<ChevronDown className={cn("size-3.5 transition-transform duration-micro", open && "rotate-180")} aria-hidden />
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<div className={cn("mt-1.5 space-y-1", className)}>
|
||||
<VersionRow v={lead} best={open && tagBest} aside={toggle} />
|
||||
{note}
|
||||
{open ? (
|
||||
<ul className="space-y-2 border-l border-border/70 pl-3">
|
||||
{rest.map((v) => (
|
||||
<li key={v.assetId}>
|
||||
<VersionRow v={v} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 电影的入库版本面板:每个文件一行,附与收录版本的比对 */
|
||||
export function MovieVersionsPanel({ entityId }: { entityId: string }) {
|
||||
const q = useLibraryVersions(entityId);
|
||||
const data = q.data;
|
||||
if (!data?.movie || data.movie.versions.length === 0) return null;
|
||||
const match = data.movie.matches?.[0];
|
||||
const target = data.targets[0];
|
||||
const versions = [...data.movie.versions].sort(
|
||||
(a, b) => Number(b.assetId === match?.assetId) - Number(a.assetId === match?.assetId),
|
||||
);
|
||||
const state = match
|
||||
? match.state === "satisfied"
|
||||
? `符合「${target?.name}」`
|
||||
: match.state === "upgradeable"
|
||||
? `可升级${match.reasons.length ? `:${match.reasons.join(";")}` : ""}`
|
||||
: null
|
||||
: null;
|
||||
return (
|
||||
<Panel title="入库版本" description={[summaryText(data.summary), state].filter(Boolean).join(" · ")}>
|
||||
<ul className="divide-y divide-border/60">
|
||||
{versions.map((v) => (
|
||||
<li key={v.assetId} className="py-2.5 first:pt-0 last:pb-0">
|
||||
<VersionRow
|
||||
v={v}
|
||||
best={versions.length > 1 && match?.state === "satisfied" && v.assetId === match.assetId}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
/** 剧集里没对上具体集数的文件 */
|
||||
export function UnplacedVersions({ versions }: { versions: AssetVersion[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!versions.length) return null;
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-border/70 px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="inline-flex w-full cursor-pointer items-center justify-between gap-2 text-left text-caption text-foreground"
|
||||
>
|
||||
<span>{versions.length} 个文件没对上集数</span>
|
||||
<ChevronDown className={cn("size-4 shrink-0 text-muted-foreground", open && "rotate-180")} aria-hidden />
|
||||
</button>
|
||||
{open ? (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{versions.map((v) => (
|
||||
<li key={v.assetId}>
|
||||
<VersionRow v={v} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -276,6 +276,8 @@ export interface EpisodeRowData {
|
||||
/** 有锁定的字段 */
|
||||
locked?: boolean;
|
||||
onEdit?: () => void;
|
||||
/** 简介下方的附加内容(入库版本) */
|
||||
extra?: ReactNode;
|
||||
}
|
||||
|
||||
function Still({ path }: { path: string | null }) {
|
||||
@@ -304,7 +306,7 @@ export function EpisodeRow({ e }: { e: EpisodeRowData }) {
|
||||
].filter(Boolean);
|
||||
const status = e.status === undefined ? (unaired ? { label: "未播出", tone: "neutral" as const } : null) : e.status;
|
||||
return (
|
||||
<li className="flex gap-3 py-3 first:pt-0 last:pb-0">
|
||||
<li className="flex items-start gap-3 py-3 first:pt-0 last:pb-0">
|
||||
<Still path={e.stillPath} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-start justify-between gap-x-2 gap-y-1">
|
||||
@@ -331,6 +333,7 @@ export function EpisodeRow({ e }: { e: EpisodeRowData }) {
|
||||
</div>
|
||||
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">{meta.join(" · ")}</p>
|
||||
{e.overview ? <ClampText text={e.overview} lines={2} className="mt-1 text-caption text-foreground/85" /> : null}
|
||||
{e.extra}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
@@ -345,6 +348,8 @@ export interface SeasonHeaderData {
|
||||
overview: string | null;
|
||||
/** 季头右侧的摘要,如「已入库 5/9」 */
|
||||
summary?: ReactNode;
|
||||
/** 季头第二行下方的一行说明(入库版本汇总) */
|
||||
detail?: ReactNode;
|
||||
}
|
||||
|
||||
/** 季折叠:季头(海报缩略、季名、首播年、集数、摘要)+ 展开后的内容(集列表由页面传入) */
|
||||
@@ -379,6 +384,9 @@ export function SeasonSection({
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "—"}
|
||||
</p>
|
||||
{s.detail ? (
|
||||
<p className="mt-0.5 text-meta break-words text-muted-foreground tabular-nums">{s.detail}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{s.summary ? <div className="hidden shrink-0 sm:block">{s.summary}</div> : null}
|
||||
<ChevronDown
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
useRefreshMetadata,
|
||||
useUnlockField,
|
||||
} from "~/api/library.ts";
|
||||
import { useLibraryVersions } from "~/api/library-versions.ts";
|
||||
import { useSubscriptionCoverage, useSubscriptions } from "~/api/subscriptions.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
@@ -62,6 +63,12 @@ import {
|
||||
import { Chip, ChipList, Panel, ProgressLine, posterPathOf, relTime, Tone } from "~/components/library/parts.tsx";
|
||||
import { SlotCard } from "~/components/library/slot-card.tsx";
|
||||
import { finalOf, UnitFieldDialog } from "~/components/library/unit-field-dialog.tsx";
|
||||
import {
|
||||
EpisodeVersionList,
|
||||
MovieVersionsPanel,
|
||||
seasonLine,
|
||||
UnplacedVersions,
|
||||
} from "~/components/library/versions.tsx";
|
||||
import { externalLinks } from "~/components/media/external-links.ts";
|
||||
import {
|
||||
AIR_STATUS,
|
||||
@@ -645,6 +652,15 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten
|
||||
.sort((a, b) => a.episodeNumber! - b.episodeNumber! || a.createdAt.localeCompare(b.createdAt));
|
||||
const covered = new Set(detail.assets.filter((a) => a.asset.status === "active").flatMap((a) => a.contentUnitIds));
|
||||
const lookup = useMemo(() => coverageLookup(cov.data, rowIdx), [cov.data, rowIdx]);
|
||||
// LIBV-1:每集的入库版本,比对跟随上方选中的收录版本
|
||||
const versions = useLibraryVersions(e.id, cov.data?.rows[rowIdx]?.slotId ?? undefined);
|
||||
const versionSeasons = versions.data?.seasons ?? [];
|
||||
// 选中「库内任一版本」时不按收录版本比对
|
||||
const anyVersion = cov.data?.rows[rowIdx] !== undefined && cov.data.rows[rowIdx]?.slotId === null;
|
||||
const versionsOf = (u: ContentUnit) => {
|
||||
const s = versionSeasons.find((x) => x.seasonNumber === u.seasonNumber);
|
||||
return s?.episodes.find((x) => x.unitId === u.id) ?? s?.episodes.find((x) => x.episodeNumber === u.episodeNumber);
|
||||
};
|
||||
const seasonNums = [
|
||||
...new Set([...seasonUnits.map((u) => u.seasonNumber!), ...episodes.map((u) => u.seasonNumber!)]),
|
||||
].sort((a, b) => (a || 999) - (b || 999));
|
||||
@@ -748,6 +764,10 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten
|
||||
episodeCount: total,
|
||||
posterPath: typeof posterV === "string" ? posterV : null,
|
||||
overview: typeof ovV === "string" ? ovV : null,
|
||||
detail: (() => {
|
||||
const vs = versionSeasons.find((x) => x.seasonNumber === n);
|
||||
return vs ? seasonLine(vs, !anyVersion) : null;
|
||||
})(),
|
||||
summary: (
|
||||
<StatusBadge tone={total && have >= total ? "success" : "neutral"}>
|
||||
已入库 {have}/{total ?? "?"}
|
||||
@@ -781,6 +801,15 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten
|
||||
status: statusOf(u),
|
||||
locked: u.fieldLocks.length > 0,
|
||||
onEdit: () => setEditing(u),
|
||||
extra: (() => {
|
||||
const ev = versionsOf(u);
|
||||
return ev ? (
|
||||
<EpisodeVersionList
|
||||
versions={ev.versions}
|
||||
match={anyVersion ? undefined : ev.matches?.[0]}
|
||||
/>
|
||||
) : null;
|
||||
})(),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -792,6 +821,7 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<UnplacedVersions versions={versions.data?.unplaced ?? []} />
|
||||
<UnitFieldDialog entityId={e.id} unit={editing} onOpenChange={(o) => !o && setEditing(null)} />
|
||||
</Panel>
|
||||
);
|
||||
@@ -1056,6 +1086,7 @@ export default function LibraryDetailPage() {
|
||||
</Panel>
|
||||
) : null}
|
||||
{e.kind !== "movie" ? <EpisodesPanel detail={d} units={entity.data?.contentUnits ?? []} /> : null}
|
||||
{e.kind === "movie" ? <MovieVersionsPanel entityId={e.id} /> : null}
|
||||
<SlotsPanel detail={d} units={entity.data?.contentUnits ?? []} />
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<IdentityPanel detail={d} />
|
||||
|
||||
@@ -178,5 +178,6 @@ NFO / 图片投影、身份巡检与挂载根因聚合、HTTP 失败自动切浏
|
||||
- **搜索快速筛选(SEARCH-3,用户 2026-09-29,对照 MP)**:搜索结果与站点资源列表顶部多选筛选条:分辨率、编码、版本类型(原盘/Remux/蓝光压制/WEB-DL/HDTV)、站点、促销、HDR/DV、制作组、季集;选项从当前结果聚合并显示计数,可组合、可清除、记住上次选择。**已实现(分支 search3)**:`qf` 参数与分面口径见 `docs/runbook/search.md`「快速筛选」。
|
||||
- **计划页与智能体(用户 2026-09-29 追加)**:计划页「Agent 巡检」不应沉底,移到上方;自动任务按分类分组、每组可折叠(并入 DEAI-2)。用户可见的"Agent / Agent 会话"统一改称「智能体」(并入 DEAI-2)。智能体对话整体偏繁琐、无效信息多 → AGUI-3 先做只读易用性审查出方案,DEAI-2 合并后再落地。
|
||||
- **入库版本可见(LIBV-1,用户 2026-09-29)**:以《人生切割术》为例,作品/季/集只显示"已入库"不够,要显示入库的是哪个版本(分辨率、来源、编码、HDR、制作组、大小、文件位置),同一集多个文件并列;为之后的"多版本收录目标"打地基(数据模型一集可挂多个版本,与「收录版本」目标可比对:已满足/缺哪个版本)。
|
||||
- 状态(libv1 分支):已实现。模型沿用 media_asset + asset_unit_coverage(一集多资产),规格规范化写 `media_asset.delivery`(`specVersion` 口径,domain `versionSpecOf` = 文件名 + ffprobe + 入库时的资源解析);`GET /library/{id}/versions` 返回每季每集版本、季 / 作品汇总、单目标比对 `matches[]`(satisfied / missing / upgradeable,多目标按数组追加);整理入库时写全规格;旧数据用 `scripts/asset-spec-backfill.ts` 回填(默认 dry-run,`--probe` 只对本地文件跑 ffprobe,`--apply` 写库)。前端:季头汇总行、每集一行规格、「N 个版本」展开、技术信息折叠(待 DEAI-2 TechInfo 合入后统一)、电影「入库版本」面板。
|
||||
- **智能体对话重做(AGUI-3,审查见 `docs/design/agent-chat-review.md`)**:按报告 P0/P1 落地,DEAI-2 合并后从新 main 开。协调者裁定:"选定方案即批准"只在方案绑定同一 planId+payloadHash 且卡片写明动作要点(对象、大小/数量)时成立;删除、覆盖类动作仍需单独确认;"先看方案再执行"改为单次指令 `/先给方案`。
|
||||
- **SEARCH-3 后续(协调者截图复核)**:新筛选条下方仍保留旧的一行(大小/免费/H&R/排序/清除筛选),"免费"与「促销」维度重复、"清除全部"与"清除筛选"并存 → 并为一行:H&R 进分面,大小与排序放右侧,只留一个"清除";元信息行的耗时进「技术信息」。DEAI-2 合并后处理。
|
||||
@@ -13,6 +13,7 @@ import * as execution from "./execution.ts";
|
||||
import * as files from "./files.ts";
|
||||
import * as identity from "./identity.ts";
|
||||
import * as instances from "./instances.ts";
|
||||
import * as libraryVersions from "./library-versions.ts";
|
||||
import * as mcp from "./mcp.ts";
|
||||
import * as ops from "./ops.ts";
|
||||
import * as organization from "./organization.ts";
|
||||
@@ -2181,6 +2182,15 @@ export const Api = {
|
||||
params: IdParam,
|
||||
response: organization.LibraryDetail,
|
||||
}),
|
||||
versions: ep({
|
||||
method: "get",
|
||||
path: "/library/{id}/versions",
|
||||
scope: "read",
|
||||
summary: "作品的入库版本:每季每集的本地文件版本与规格、季 / 作品汇总、与版本目标的比对",
|
||||
params: IdParam,
|
||||
query: libraryVersions.LibraryVersionsQuery,
|
||||
response: libraryVersions.LibraryVersions,
|
||||
}),
|
||||
reconcile: ep({
|
||||
method: "post",
|
||||
path: "/library/reconcile",
|
||||
|
||||
@@ -19,6 +19,16 @@ export const DeliveryTraits = z.object({
|
||||
sourceType: z.string().optional(),
|
||||
releaseGroup: z.string().optional(),
|
||||
subtitles: z.array(z.string()).optional(),
|
||||
// LIBV-1 版本规格(domain deliveryOfSpec 写入;specVersion 标记口径,缺省 = 旧数据待回填)
|
||||
specVersion: z.number().int().optional(),
|
||||
specFrom: z.array(z.enum(["name", "probe"])).optional(),
|
||||
isEncode: z.boolean().optional(),
|
||||
bitDepth: z.number().int().optional(),
|
||||
audio: z
|
||||
.array(z.object({ codec: z.string().optional(), channels: z.string().optional(), language: z.string().optional() }))
|
||||
.optional(),
|
||||
edition: z.string().optional(),
|
||||
durationSec: z.number().int().optional(),
|
||||
});
|
||||
export type DeliveryTraits = z.infer<typeof DeliveryTraits>;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export * from "./events.ts";
|
||||
export * from "./execution.ts";
|
||||
export * from "./identity.ts";
|
||||
export * from "./instances.ts";
|
||||
export * from "./library-versions.ts";
|
||||
export * from "./ops.ts";
|
||||
export * from "./organization.ts";
|
||||
export * from "./pi.ts";
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { z } from "zod";
|
||||
import { AssetStatus, LocationStatus } from "./assets.ts";
|
||||
import { Id, JsonObject, NonNegInt, Timestamp } from "./common.ts";
|
||||
import { MediaKind } from "./content.ts";
|
||||
|
||||
// LIBV-1 入库版本(GET /library/{id}/versions):一集(或一部电影)可挂多个本地文件,每个文件是一个版本。
|
||||
// 规格来自文件名解析 + ffprobe(domain versionSpecOf);季 / 作品给汇总;
|
||||
// 配了版本目标时每集给出比对结果 matches(目前只比一个目标,数组形态留给多目标)。
|
||||
|
||||
export const VersionAudio = z.object({
|
||||
codec: z.string().optional(),
|
||||
channels: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
});
|
||||
export type VersionAudio = z.infer<typeof VersionAudio>;
|
||||
|
||||
export const AssetVersion = z.object({
|
||||
assetId: Id,
|
||||
status: AssetStatus,
|
||||
resolution: z.string().nullable(),
|
||||
/** REMUX / BluRay(压制)/ BluRay-Disc(原盘)/ WEB-DL / WEBRip / HDTV … */
|
||||
source: z.string().nullable(),
|
||||
isEncode: z.boolean(),
|
||||
videoCodec: z.string().nullable(),
|
||||
bitDepth: z.number().int().nullable(),
|
||||
hdr: z.array(z.string()),
|
||||
audio: z.array(VersionAudio),
|
||||
subtitleLanguages: z.array(z.string()),
|
||||
releaseGroup: z.string().nullable(),
|
||||
/** 剪辑版本(导演剪辑版等),无标记为 null */
|
||||
edition: z.string().nullable(),
|
||||
sizeBytes: NonNegInt.nullable(),
|
||||
durationSec: NonNegInt.nullable(),
|
||||
fileName: z.string(),
|
||||
relativePath: z.string().nullable(),
|
||||
/** 库根本地路径 + 相对路径;库根未知时为 null */
|
||||
absolutePath: z.string().nullable(),
|
||||
libraryRootId: Id.nullable(),
|
||||
locationStatus: LocationStatus.nullable(),
|
||||
/** 入库时间(资产登记时间) */
|
||||
organizedAt: Timestamp,
|
||||
downloadTaskId: Id.nullable(),
|
||||
sourceReleaseId: Id.nullable(),
|
||||
/** 规格来源:name = 文件名,probe = ffprobe */
|
||||
specFrom: z.array(z.enum(["name", "probe"])),
|
||||
/** delivery 已按当前口径写回;false = 本次现算,可跑回填脚本 */
|
||||
specStored: z.boolean(),
|
||||
/** ffprobe 原始摘要(技术信息折叠里展示) */
|
||||
probe: JsonObject.nullable(),
|
||||
/** 覆盖的内容单元(合集文件覆盖多集) */
|
||||
unitIds: z.array(Id),
|
||||
});
|
||||
export type AssetVersion = z.infer<typeof AssetVersion>;
|
||||
|
||||
export const VersionSpecGroup = z.object({
|
||||
resolution: z.string().nullable(),
|
||||
source: z.string().nullable(),
|
||||
hdr: z.array(z.string()),
|
||||
count: NonNegInt,
|
||||
});
|
||||
export const VersionSummary = z.object({
|
||||
versionCount: NonNegInt,
|
||||
sizeBytes: NonNegInt,
|
||||
/** 按 分辨率 + 来源 + HDR 分组,多的在前 */
|
||||
specs: z.array(VersionSpecGroup),
|
||||
});
|
||||
export type VersionSummary = z.infer<typeof VersionSummary>;
|
||||
|
||||
export const UnitMatchState = z.enum(["satisfied", "missing", "upgradeable"]);
|
||||
export type UnitMatchState = z.infer<typeof UnitMatchState>;
|
||||
export const UnitMatch = z.object({
|
||||
slotId: Id.nullable(),
|
||||
state: UnitMatchState,
|
||||
assetId: Id.nullable(),
|
||||
reasons: z.array(z.string()),
|
||||
});
|
||||
export type UnitMatch = z.infer<typeof UnitMatch>;
|
||||
export const MatchCounts = z.object({ satisfied: NonNegInt, missing: NonNegInt, upgradeable: NonNegInt });
|
||||
export type MatchCounts = z.infer<typeof MatchCounts>;
|
||||
|
||||
export const EpisodeVersions = z.object({
|
||||
unitId: Id,
|
||||
episodeNumber: z.number().int(),
|
||||
segment: z.string().nullable(),
|
||||
versions: z.array(AssetVersion),
|
||||
/** 与各版本目标的比对;没有版本目标时不出现 */
|
||||
matches: z.array(UnitMatch).optional(),
|
||||
});
|
||||
export type EpisodeVersions = z.infer<typeof EpisodeVersions>;
|
||||
|
||||
export const SeasonVersions = z.object({
|
||||
seasonNumber: z.number().int(),
|
||||
unitId: Id.nullable(),
|
||||
/** 该季总集数(元数据);未知为 null */
|
||||
episodeTotal: NonNegInt.nullable(),
|
||||
/** 至少有一个版本的集数 */
|
||||
episodesWithVersion: NonNegInt,
|
||||
summary: VersionSummary,
|
||||
episodes: z.array(EpisodeVersions),
|
||||
/** 按第一个版本目标统计;没有版本目标时不出现 */
|
||||
matchCounts: MatchCounts.optional(),
|
||||
});
|
||||
export type SeasonVersions = z.infer<typeof SeasonVersions>;
|
||||
|
||||
export const LibraryVersionsQuery = z.object({
|
||||
/** 比对哪个版本目标;缺省取作品的主版本目标 */
|
||||
slotId: z.uuid().optional(),
|
||||
});
|
||||
export type LibraryVersionsQuery = z.infer<typeof LibraryVersionsQuery>;
|
||||
|
||||
export const LibraryVersions = z.object({
|
||||
entityId: Id,
|
||||
kind: MediaKind,
|
||||
/** 参与比对的版本目标(当前最多一个);空 = 未配置 */
|
||||
targets: z.array(z.object({ slotId: Id, name: z.string() })),
|
||||
summary: VersionSummary,
|
||||
seasons: z.array(SeasonVersions),
|
||||
/** 电影:全部版本与比对 */
|
||||
movie: z.object({ versions: z.array(AssetVersion), matches: z.array(UnitMatch).optional() }).nullable(),
|
||||
/** 剧集里没对上具体某一集的视频文件 */
|
||||
unplaced: z.array(AssetVersion),
|
||||
});
|
||||
export type LibraryVersions = z.infer<typeof LibraryVersions>;
|
||||
@@ -4,6 +4,7 @@ export const PACKAGE_NAME = "@umt/domain";
|
||||
export * from "./acquisition/index.ts";
|
||||
export * from "./download-state/index.ts";
|
||||
export * from "./identity/index.ts";
|
||||
export * from "./library/versions.ts";
|
||||
export * from "./naming/index.ts";
|
||||
export * from "./organize/index.ts";
|
||||
export * from "./path/index.ts";
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
import { parseReleasePath } from "../release-parser/path.ts";
|
||||
import type { Cut, HdrFormat, ParsedRelease, ReleaseSource, Resolution, VideoCodec } from "../release-parser/types.ts";
|
||||
import {
|
||||
currentPri,
|
||||
DEFAULT_SOURCE_TIERS,
|
||||
describeViolation,
|
||||
evaluateCandidate,
|
||||
qualityTier,
|
||||
RESOLUTION_RANK,
|
||||
} from "../slot/evaluate.ts";
|
||||
import type { SlotSpec } from "../slot/types.ts";
|
||||
|
||||
// LIBV-1 入库版本:一集(或一部电影)可以挂多个本地文件,每个文件是一个"版本"。
|
||||
// 版本规格 = 文件名解析 + ffprobe 结果合并:技术事实(分辨率、编码、HDR、音轨、字幕、时长)以探测为准,
|
||||
// 来源(原盘/Remux/压制/WEB-DL)、制作组、剪辑版本只能从文件名得到。
|
||||
// 规范化后的规格写回 media_asset.delivery(specVersion 标记口径),读取时口径不符就现算。
|
||||
|
||||
export const VERSION_SPEC_VERSION = 1;
|
||||
|
||||
/** ffprobe 结果的结构子集(domain 不依赖 adapters) */
|
||||
export interface ProbeLike {
|
||||
status?: string;
|
||||
container?: string;
|
||||
durationSec?: number;
|
||||
video?: { codec?: string; profile?: string; width?: number; height?: number; pixFmt?: string; hdr?: string }[];
|
||||
audio?: {
|
||||
codec?: string;
|
||||
profile?: string;
|
||||
channels?: number;
|
||||
channelLayout?: string;
|
||||
language?: string;
|
||||
title?: string;
|
||||
}[];
|
||||
subtitles?: { codec?: string; language?: string; title?: string; forced?: boolean }[];
|
||||
}
|
||||
|
||||
export interface VersionAudio {
|
||||
/** TrueHD Atmos / DDP / DTS-HD MA / AAC … */
|
||||
codec?: string;
|
||||
/** 7.1 / 5.1 / 2.0 */
|
||||
channels?: string;
|
||||
/** zh / en / ja …;未知不给 */
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface VersionSpec {
|
||||
resolution?: Resolution;
|
||||
source?: ReleaseSource;
|
||||
/** 蓝光二次压制 */
|
||||
isEncode?: boolean;
|
||||
videoCodec?: VideoCodec;
|
||||
/** 按 DV → HDR10+ → HDR10 → HLG → HDR Vivid 排序;空 = SDR 或未知 */
|
||||
hdr: HdrFormat[];
|
||||
bitDepth?: number;
|
||||
audio: VersionAudio[];
|
||||
/** 内嵌 + 外挂字幕语言(zh-Hans / zh-Hant / zh / en …) */
|
||||
subtitleLanguages: string[];
|
||||
releaseGroup?: string;
|
||||
edition?: Cut;
|
||||
durationSec?: number;
|
||||
/** 规格来自哪里:name = 文件名解析,probe = ffprobe */
|
||||
specFrom: ("name" | "probe")[];
|
||||
}
|
||||
|
||||
const HDR_ORDER: HdrFormat[] = ["DV", "HDR10+", "HDR10", "HLG", "HDR Vivid"];
|
||||
|
||||
export function resolutionFromDims(width?: number, height?: number): Resolution | undefined {
|
||||
const w = width ?? 0;
|
||||
const h = height ?? 0;
|
||||
if (!w && !h) return undefined;
|
||||
if (w >= 7000 || h >= 4000) return "4320p";
|
||||
if (w >= 3200 || h >= 1800) return "2160p";
|
||||
if (w >= 2400 || h >= 1300) return "1440p";
|
||||
if (w >= 1800 || h >= 1000) return "1080p";
|
||||
if (w >= 1200 || h >= 700) return "720p";
|
||||
if (w >= 1000 || h >= 540) return "576p";
|
||||
return "480p";
|
||||
}
|
||||
|
||||
const VIDEO_CODEC: Record<string, VideoCodec> = {
|
||||
hevc: "H.265",
|
||||
h265: "H.265",
|
||||
h264: "H.264",
|
||||
avc: "H.264",
|
||||
av1: "AV1",
|
||||
vc1: "VC-1",
|
||||
mpeg2video: "MPEG-2",
|
||||
};
|
||||
|
||||
export function videoCodecOf(codec?: string): VideoCodec | undefined {
|
||||
return codec ? VIDEO_CODEC[codec.toLowerCase()] : undefined;
|
||||
}
|
||||
|
||||
/** ffprobe 音频编码 → 常用写法;Atmos / DTS:X 从 profile 或轨道标题识别 */
|
||||
export function audioCodecOf(codec?: string, profile?: string, title?: string): string | undefined {
|
||||
if (!codec) return undefined;
|
||||
const c = codec.toLowerCase();
|
||||
const hint = `${profile ?? ""} ${title ?? ""}`;
|
||||
const atmos = /atmos/i.test(hint) ? " Atmos" : "";
|
||||
if (c === "truehd") return `TrueHD${atmos}`;
|
||||
if (c === "eac3") return `DDP${atmos}`;
|
||||
if (c === "ac3") return "DD";
|
||||
if (c === "dts") {
|
||||
if (/dts[:-]?x\b/i.test(hint)) return "DTS:X";
|
||||
if (/\bma\b|master/i.test(hint)) return "DTS-HD MA";
|
||||
if (/hra|high res/i.test(hint)) return "DTS-HD HRA";
|
||||
return "DTS";
|
||||
}
|
||||
if (c === "aac") return "AAC";
|
||||
if (c === "flac") return "FLAC";
|
||||
if (c === "opus") return "Opus";
|
||||
if (c === "mp3") return "MP3";
|
||||
if (c.startsWith("pcm_")) return "LPCM";
|
||||
return codec.toUpperCase();
|
||||
}
|
||||
|
||||
const CHANNELS: Record<number, string> = { 1: "1.0", 2: "2.0", 3: "2.1", 6: "5.1", 7: "6.1", 8: "7.1" };
|
||||
|
||||
export function channelsLabel(channels?: number, layout?: string): string | undefined {
|
||||
const m = layout?.match(/^(\d\.\d)/);
|
||||
if (m) return m[1];
|
||||
if (layout === "stereo") return "2.0";
|
||||
if (layout === "mono") return "1.0";
|
||||
if (channels === undefined) return undefined;
|
||||
return CHANNELS[channels] ?? `${channels}ch`;
|
||||
}
|
||||
|
||||
const LANG: Record<string, string> = {
|
||||
chi: "zh",
|
||||
zho: "zh",
|
||||
chs: "zh-Hans",
|
||||
cht: "zh-Hant",
|
||||
sc: "zh-Hans",
|
||||
tc: "zh-Hant",
|
||||
eng: "en",
|
||||
jpn: "ja",
|
||||
jap: "ja",
|
||||
kor: "ko",
|
||||
fre: "fr",
|
||||
fra: "fr",
|
||||
ger: "de",
|
||||
deu: "de",
|
||||
spa: "es",
|
||||
ita: "it",
|
||||
rus: "ru",
|
||||
por: "pt",
|
||||
tha: "th",
|
||||
cantonese: "yue",
|
||||
yue: "yue",
|
||||
};
|
||||
|
||||
/** 轨道语言 → 短码;中文按标题区分简繁;und / 空 → undefined */
|
||||
export function languageOf(language?: string, title?: string): string | undefined {
|
||||
const l = (language ?? "").toLowerCase();
|
||||
const t = title ?? "";
|
||||
if (!l || l === "und") {
|
||||
if (/简|chs|simplified/i.test(t)) return "zh-Hans";
|
||||
if (/繁|cht|traditional/i.test(t)) return "zh-Hant";
|
||||
return undefined;
|
||||
}
|
||||
const code = LANG[l] ?? l;
|
||||
if (code === "zh") {
|
||||
if (/简|chs|simplified|gb/i.test(t)) return "zh-Hans";
|
||||
if (/繁|cht|traditional|big5/i.test(t)) return "zh-Hant";
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
/** 外挂字幕文件名里的语言记号:xxx.chs.srt / xxx.zh-Hant.ass / xxx.eng.srt */
|
||||
export function sidecarLanguageOf(fileName: string): string | undefined {
|
||||
const parts = fileName.toLowerCase().split(".");
|
||||
parts.pop();
|
||||
for (const p of parts.slice(-2).reverse()) {
|
||||
if (p === "zh-hans" || p === "zh-cn") return "zh-Hans";
|
||||
if (p === "zh-hant" || p === "zh-tw" || p === "zh-hk") return "zh-Hant";
|
||||
if (p === "zh" || p === "chi" || p === "zho") return "zh";
|
||||
if (LANG[p]) return LANG[p];
|
||||
if (p === "en" || p === "ja" || p === "ko") return p;
|
||||
}
|
||||
if (/简|chs/i.test(fileName)) return "zh-Hans";
|
||||
if (/繁|cht/i.test(fileName)) return "zh-Hant";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const uniq = <T>(xs: readonly T[]) => [...new Set(xs)];
|
||||
|
||||
export function sortHdr(xs: readonly string[]): HdrFormat[] {
|
||||
const set = new Set(xs.filter((x): x is HdrFormat => (HDR_ORDER as string[]).includes(x)));
|
||||
if (set.has("HDR10+")) set.delete("HDR10");
|
||||
return HDR_ORDER.filter((h) => set.has(h));
|
||||
}
|
||||
|
||||
/** 文件名里的音频记号 "DDP 5.1" + "Atmos" → [{ codec: "DDP Atmos", channels: "5.1" }] */
|
||||
function audioFromName(tokens: readonly string[]): VersionAudio[] {
|
||||
const out: VersionAudio[] = [];
|
||||
for (const t of tokens) {
|
||||
if (/^atmos$/i.test(t)) {
|
||||
const last = out[out.length - 1];
|
||||
if (last?.codec && !/atmos/i.test(last.codec)) last.codec = `${last.codec} Atmos`;
|
||||
else if (!last) out.push({ codec: "Atmos" });
|
||||
continue;
|
||||
}
|
||||
const m = t.match(/^(.*?)\s*(\d\.\d)$/);
|
||||
out.push(m ? { codec: m[1] || undefined, channels: m[2] } : { codec: t });
|
||||
}
|
||||
return out.map((a) => Object.fromEntries(Object.entries(a).filter(([, v]) => v !== undefined)) as VersionAudio);
|
||||
}
|
||||
|
||||
export interface VersionSpecInput {
|
||||
/** 库内相对路径(含作品/季目录,解析时目录名参与) */
|
||||
path: string;
|
||||
probe?: ProbeLike | null;
|
||||
/** 外挂字幕文件名(同主视频的附属资产) */
|
||||
sidecarSubtitles?: readonly string[];
|
||||
/** 已解析的结果(省一次解析,测试用) */
|
||||
parsed?: ParsedRelease;
|
||||
}
|
||||
|
||||
/** 文件名解析 + 探测 → 版本规格 */
|
||||
export function versionSpecOf(input: VersionSpecInput): VersionSpec {
|
||||
const r = input.parsed ?? parseReleasePath(input.path, { isFile: true });
|
||||
const p = input.probe?.status === "ok" ? input.probe : null;
|
||||
const v = p?.video?.[0];
|
||||
const from: VersionSpec["specFrom"] = [];
|
||||
const nameHas = !!(r.resolution || r.source || r.videoCodec || r.releaseGroup || r.hdr.length || r.audio.length);
|
||||
if (nameHas) from.push("name");
|
||||
if (p) from.push("probe");
|
||||
const audio = p?.audio?.length
|
||||
? p.audio.map((a) => {
|
||||
const o: VersionAudio = {};
|
||||
const codec = audioCodecOf(a.codec, a.profile, a.title);
|
||||
const ch = channelsLabel(a.channels, a.channelLayout);
|
||||
const lang = languageOf(a.language, a.title);
|
||||
if (codec) o.codec = codec;
|
||||
if (ch) o.channels = ch;
|
||||
if (lang) o.language = lang;
|
||||
return o;
|
||||
})
|
||||
: audioFromName(r.audio);
|
||||
const subs = [
|
||||
...(p?.subtitles ?? []).map((s) => languageOf(s.language, s.title)),
|
||||
...(r.subtitles ?? []),
|
||||
...(input.sidecarSubtitles ?? []).map(sidecarLanguageOf),
|
||||
].filter((x): x is string => !!x);
|
||||
const bitDepth = v?.pixFmt?.match(/p(10|12)(le|be)?$/)?.[1];
|
||||
const spec: VersionSpec = {
|
||||
hdr: sortHdr([...(v?.hdr ? [v.hdr] : []), ...r.hdr]),
|
||||
audio,
|
||||
subtitleLanguages: uniq(subs),
|
||||
specFrom: from,
|
||||
};
|
||||
const res = resolutionFromDims(v?.width, v?.height) ?? r.resolution;
|
||||
if (res) spec.resolution = res;
|
||||
if (r.source) spec.source = r.source;
|
||||
if (r.source) spec.isEncode = r.isEncode;
|
||||
const vc = videoCodecOf(v?.codec) ?? r.videoCodec;
|
||||
if (vc) spec.videoCodec = vc;
|
||||
const bd = bitDepth ? Number(bitDepth) : r.bitDepth;
|
||||
if (bd) spec.bitDepth = bd;
|
||||
if (r.releaseGroup) spec.releaseGroup = r.releaseGroup.name;
|
||||
if (r.cut) spec.edition = r.cut;
|
||||
if (p?.durationSec) spec.durationSec = Math.round(p.durationSec);
|
||||
return spec;
|
||||
}
|
||||
|
||||
/** 写回 media_asset.delivery 的形态(DeliveryTraits 的超集,旧字段保持同名) */
|
||||
export function deliveryOfSpec(s: VersionSpec): Record<string, unknown> {
|
||||
return {
|
||||
specVersion: VERSION_SPEC_VERSION,
|
||||
specFrom: s.specFrom,
|
||||
...(s.resolution ? { resolution: s.resolution } : {}),
|
||||
...(s.source ? { sourceType: s.source, isEncode: !!s.isEncode } : {}),
|
||||
...(s.videoCodec ? { videoCodec: s.videoCodec } : {}),
|
||||
...(s.bitDepth ? { bitDepth: s.bitDepth } : {}),
|
||||
hdr: s.hdr,
|
||||
audio: s.audio,
|
||||
audioCodecs: uniq(s.audio.map((a) => a.codec).filter((x): x is string => !!x)),
|
||||
subtitles: s.subtitleLanguages,
|
||||
...(s.releaseGroup ? { releaseGroup: s.releaseGroup } : {}),
|
||||
...(s.edition ? { edition: s.edition } : {}),
|
||||
...(s.durationSec ? { durationSec: s.durationSec } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 已按当前口径写回的 delivery → 规格;口径不符(旧数据)返回 null,由调用方现算 */
|
||||
export function specFromDelivery(d: unknown): VersionSpec | null {
|
||||
if (!d || typeof d !== "object") return null;
|
||||
const o = d as Record<string, unknown>;
|
||||
if (o.specVersion !== VERSION_SPEC_VERSION) return null;
|
||||
const str = (k: string) => (typeof o[k] === "string" ? (o[k] as string) : undefined);
|
||||
const arr = <T>(k: string) => (Array.isArray(o[k]) ? (o[k] as T[]) : []);
|
||||
const spec: VersionSpec = {
|
||||
hdr: sortHdr(arr<string>("hdr")),
|
||||
audio: arr<VersionAudio>("audio"),
|
||||
subtitleLanguages: arr<string>("subtitles"),
|
||||
specFrom: arr<"name" | "probe">("specFrom"),
|
||||
};
|
||||
if (str("resolution")) spec.resolution = str("resolution") as Resolution;
|
||||
if (str("sourceType")) {
|
||||
spec.source = str("sourceType") as ReleaseSource;
|
||||
spec.isEncode = o.isEncode === true;
|
||||
}
|
||||
if (str("videoCodec")) spec.videoCodec = str("videoCodec") as VideoCodec;
|
||||
if (typeof o.bitDepth === "number") spec.bitDepth = o.bitDepth;
|
||||
if (str("releaseGroup")) spec.releaseGroup = str("releaseGroup");
|
||||
if (str("edition")) spec.edition = str("edition") as Cut;
|
||||
if (typeof o.durationSec === "number") spec.durationSec = o.durationSec;
|
||||
return spec;
|
||||
}
|
||||
|
||||
// ── 汇总 ──
|
||||
|
||||
export interface SpecGroup {
|
||||
resolution: Resolution | null;
|
||||
source: ReleaseSource | null;
|
||||
hdr: HdrFormat[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface VersionSummary {
|
||||
versionCount: number;
|
||||
sizeBytes: number;
|
||||
/** 按 分辨率 + 来源 + HDR 分组,数量多的在前 */
|
||||
specs: SpecGroup[];
|
||||
}
|
||||
|
||||
export function summarizeVersions(
|
||||
items: readonly { spec: Pick<VersionSpec, "resolution" | "source" | "hdr">; sizeBytes: number | null }[],
|
||||
): VersionSummary {
|
||||
const groups = new Map<string, SpecGroup>();
|
||||
let size = 0;
|
||||
for (const it of items) {
|
||||
size += it.sizeBytes ?? 0;
|
||||
const hdr = it.spec.hdr.filter((h) => h !== "HDR10" || !it.spec.hdr.includes("DV"));
|
||||
const key = `${it.spec.resolution ?? ""}|${it.spec.source ?? ""}|${hdr.join("+")}`;
|
||||
const g = groups.get(key);
|
||||
if (g) g.count++;
|
||||
else groups.set(key, { resolution: it.spec.resolution ?? null, source: it.spec.source ?? null, hdr, count: 1 });
|
||||
}
|
||||
const rank = (g: SpecGroup) => (g.resolution ? RESOLUTION_RANK[g.resolution] : -1);
|
||||
return {
|
||||
versionCount: items.length,
|
||||
sizeBytes: size,
|
||||
specs: [...groups.values()].sort((a, b) => b.count - a.count || rank(b) - rank(a)),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 与版本目标比对 ──
|
||||
|
||||
export type UnitMatchState = "satisfied" | "missing" | "upgradeable";
|
||||
|
||||
export interface UnitMatch {
|
||||
slotId: string | null;
|
||||
state: UnitMatchState;
|
||||
/** 满足目标的最好版本(satisfied / 可升级但已达标时);都不达标时为最接近的版本 */
|
||||
assetId: string | null;
|
||||
/** 可读原因:不达标的条件、升级余量 */
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface LocalVersion {
|
||||
assetId: string;
|
||||
path: string;
|
||||
spec: VersionSpec;
|
||||
sizeBytes: number | null;
|
||||
/** 该文件覆盖的内容单元数(合集文件按集折算体积) */
|
||||
unitCount?: number;
|
||||
}
|
||||
|
||||
/** 本地文件与站点资源不同:站点范围、做种数、促销、标题关键词不适用;剧集按单集体积比较 */
|
||||
export function localSlotSpec(spec: SlotSpec, isEpisode: boolean): SlotSpec {
|
||||
const { sites: _s, excludeSites: _xs, minSeeders: _m, freeOnly: _f, include: _i, exclude: _x, ...hard } = spec.hard;
|
||||
if (isEpisode) {
|
||||
delete hard.maxSizeBytes;
|
||||
delete hard.minSizeBytes;
|
||||
}
|
||||
return { ...spec, hard, unknownPolicy: "accept" };
|
||||
}
|
||||
|
||||
function pseudoRelease(v: LocalVersion): ParsedRelease {
|
||||
const s = v.spec;
|
||||
return {
|
||||
parserVersion: 0,
|
||||
raw: v.path,
|
||||
isEncode: !!s.isEncode,
|
||||
audio: s.audio.map((a) => [a.codec, a.channels].filter(Boolean).join(" ")),
|
||||
hdr: s.hdr,
|
||||
contentType: "unknown",
|
||||
variantTags: [],
|
||||
mediaTypeHint: "unknown",
|
||||
isSeasonPack: false,
|
||||
packHint: "single",
|
||||
isFile: true,
|
||||
explicitIds: [],
|
||||
confidence: "rule_confirmed",
|
||||
evidence: [],
|
||||
...(s.resolution ? { resolution: s.resolution } : {}),
|
||||
...(s.source ? { source: s.source } : {}),
|
||||
...(s.videoCodec ? { videoCodec: s.videoCodec } : {}),
|
||||
...(s.releaseGroup ? { releaseGroup: { name: s.releaseGroup, source: "known" as const } } : {}),
|
||||
...(s.edition ? { cut: s.edition } : {}),
|
||||
// 只有探测过才知道内嵌字幕;只凭文件名时视为未知(不据此判不达标)
|
||||
...(s.specFrom.includes("probe") || s.subtitleLanguages.length ? { subtitles: s.subtitleLanguages } : {}),
|
||||
...(v.sizeBytes !== null ? { sizeBytes: v.sizeBytes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 目标允许的最高来源等级(未限制来源时为等级表最高) */
|
||||
function ceilingOf(spec: SlotSpec): { source: number; resolution: number } {
|
||||
const tiers = { ...DEFAULT_SOURCE_TIERS, ...spec.upgrade?.sourceTiers };
|
||||
const allowed = (spec.hard.sources?.length ? spec.hard.sources : (Object.keys(tiers) as ReleaseSource[])).filter(
|
||||
(s) => !spec.hard.excludeSources?.includes(s),
|
||||
);
|
||||
const res = spec.hard.resolutions?.length ? spec.hard.resolutions : (Object.keys(RESOLUTION_RANK) as Resolution[]);
|
||||
return {
|
||||
source: Math.max(-1, ...allowed.map((s) => tiers[s])),
|
||||
resolution: Math.max(-1, ...res.map((r) => RESOLUTION_RANK[r])),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 一集(或一部电影)的全部版本与一个版本目标比对:
|
||||
* missing = 没有版本;satisfied = 有版本达标且没有升级余量;
|
||||
* upgradeable = 有版本但都不达标,或达标版本在目标的升级策略下还能换更好的。
|
||||
*/
|
||||
export function matchVersions(
|
||||
target: SlotSpec,
|
||||
versions: readonly LocalVersion[],
|
||||
opts: { isEpisode: boolean },
|
||||
): UnitMatch {
|
||||
const slotId = target.id ?? null;
|
||||
if (versions.length === 0) return { slotId, state: "missing", assetId: null, reasons: [] };
|
||||
const spec = localSlotSpec(target, opts.isEpisode);
|
||||
const evals = versions.map((v) => {
|
||||
const r = pseudoRelease(v);
|
||||
const ev = evaluateCandidate(spec, r, null, v.unitCount ? { unitCount: v.unitCount } : {});
|
||||
return { v, r, ev, tier: qualityTier(r, spec) };
|
||||
});
|
||||
const better = (a: (typeof evals)[number], b: (typeof evals)[number]) =>
|
||||
(b.ev.decision === "accept" ? (b.ev.pri ?? 0) : 0) - (a.ev.decision === "accept" ? (a.ev.pri ?? 0) : 0) ||
|
||||
b.tier.source - a.tier.source ||
|
||||
b.tier.resolution - a.tier.resolution;
|
||||
const ok = evals.filter((e) => e.ev.decision === "accept").sort(better);
|
||||
const best = ok[0];
|
||||
if (!best) {
|
||||
const near = [...evals].sort(better)[0]!;
|
||||
const reasons = near.ev.decision === "reject" ? uniq(near.ev.violations.map(describeViolation)) : ["规格未知"];
|
||||
return { slotId, state: "upgradeable", assetId: near.v.assetId, reasons };
|
||||
}
|
||||
const up = target.upgrade;
|
||||
if (!up?.enabled) return { slotId, state: "satisfied", assetId: best.v.assetId, reasons: [] };
|
||||
const tiered = !!target.soft?.tiers?.length || !!target.rules?.groups.length;
|
||||
const basis = up.basis ?? (tiered ? "priority" : "tier");
|
||||
if (basis === "priority") {
|
||||
const pri = best.ev.decision === "accept" ? (best.ev.pri ?? currentPri(spec, best.r)) : 0;
|
||||
const goal = up.targetPri ?? 100;
|
||||
return pri < goal
|
||||
? { slotId, state: "upgradeable", assetId: best.v.assetId, reasons: [`优先级 ${pri},目标 ${goal}`] }
|
||||
: { slotId, state: "satisfied", assetId: best.v.assetId, reasons: [] };
|
||||
}
|
||||
const ceil = ceilingOf(target);
|
||||
const belowCeiling = up.ceilingTier === undefined || best.tier.source < up.ceilingTier;
|
||||
const bySource = belowCeiling && best.tier.source + (up.minTierSteps ?? 1) <= ceil.source;
|
||||
const byRes = !!up.allowResolutionUpgrade && best.tier.resolution < ceil.resolution;
|
||||
if (bySource || byRes)
|
||||
return {
|
||||
slotId,
|
||||
state: "upgradeable",
|
||||
assetId: best.v.assetId,
|
||||
reasons: [bySource ? "来源还能升级" : "分辨率还能升级"],
|
||||
};
|
||||
return { slotId, state: "satisfied", assetId: best.v.assetId, reasons: [] };
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
audioCodecOf,
|
||||
channelsLabel,
|
||||
deliveryOfSpec,
|
||||
type LocalVersion,
|
||||
languageOf,
|
||||
matchVersions,
|
||||
type ProbeLike,
|
||||
resolutionFromDims,
|
||||
type SlotSpec,
|
||||
sidecarLanguageOf,
|
||||
specFromDelivery,
|
||||
summarizeVersions,
|
||||
VERSION_SPEC_VERSION,
|
||||
versionSpecOf,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const GB = 1e9;
|
||||
const DV_FILE = "人生切割术 (2022)/Season 1/Severance.S01E01.2160p.ATVP.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv";
|
||||
const BD_FILE = "人生切割术 (2022)/Season 1/Severance.S01E01.1080p.BluRay.x264.DTS-HD.MA.5.1-CHD.mkv";
|
||||
|
||||
const PROBE_DV: ProbeLike = {
|
||||
status: "ok",
|
||||
durationSec: 3421.6,
|
||||
video: [{ codec: "hevc", width: 3840, height: 1606, pixFmt: "yuv420p10le", hdr: "DV" }],
|
||||
audio: [
|
||||
{
|
||||
codec: "eac3",
|
||||
profile: "Dolby Digital Plus + Dolby Atmos",
|
||||
channels: 6,
|
||||
channelLayout: "5.1(side)",
|
||||
language: "eng",
|
||||
},
|
||||
{ codec: "aac", channels: 2, language: "chi", title: "国语" },
|
||||
],
|
||||
subtitles: [
|
||||
{ codec: "subrip", language: "chi", title: "简体" },
|
||||
{ codec: "subrip", language: "chi", title: "繁體 cht" },
|
||||
{ codec: "subrip", language: "eng" },
|
||||
],
|
||||
};
|
||||
|
||||
describe("版本规格:文件名 + 探测", () => {
|
||||
it("探测优先给技术事实,来源 / 制作组来自文件名", () => {
|
||||
const s = versionSpecOf({ path: DV_FILE, probe: PROBE_DV });
|
||||
expect(s).toMatchObject({
|
||||
resolution: "2160p",
|
||||
source: "WEB-DL",
|
||||
isEncode: false,
|
||||
videoCodec: "H.265",
|
||||
hdr: ["DV", "HDR10"],
|
||||
bitDepth: 10,
|
||||
releaseGroup: "FLUX",
|
||||
durationSec: 3422,
|
||||
specFrom: ["name", "probe"],
|
||||
});
|
||||
expect(s.audio).toEqual([
|
||||
{ codec: "DDP Atmos", channels: "5.1", language: "en" },
|
||||
{ codec: "AAC", channels: "2.0", language: "zh" },
|
||||
]);
|
||||
expect(s.subtitleLanguages).toEqual(["zh-Hans", "zh-Hant", "en"]);
|
||||
});
|
||||
|
||||
it("没有探测结果只用文件名;外挂字幕按文件名记语言", () => {
|
||||
const s = versionSpecOf({
|
||||
path: BD_FILE,
|
||||
sidecarSubtitles: ["Severance.S01E01.chs.srt", "Severance.S01E01.eng.ass"],
|
||||
});
|
||||
expect(s).toMatchObject({
|
||||
resolution: "1080p",
|
||||
source: "BluRay",
|
||||
isEncode: true,
|
||||
videoCodec: "H.264",
|
||||
hdr: [],
|
||||
releaseGroup: "CHD",
|
||||
specFrom: ["name"],
|
||||
});
|
||||
expect(s.audio).toEqual([{ codec: "DTS-HD MA", channels: "5.1" }]);
|
||||
expect(s.subtitleLanguages).toEqual(["zh-Hans", "en"]);
|
||||
expect(s.durationSec).toBeUndefined();
|
||||
});
|
||||
|
||||
it("裁切过的 4K(3840x1600)仍算 2160p;小工具函数", () => {
|
||||
expect(resolutionFromDims(3840, 1600)).toBe("2160p");
|
||||
expect(resolutionFromDims(1920, 800)).toBe("1080p");
|
||||
expect(resolutionFromDims(1280, 536)).toBe("720p");
|
||||
expect(resolutionFromDims()).toBeUndefined();
|
||||
expect(audioCodecOf("truehd", undefined, "TrueHD Atmos 7.1")).toBe("TrueHD Atmos");
|
||||
expect(audioCodecOf("dts", "DTS-HD MA + DTS:X")).toBe("DTS:X");
|
||||
expect(audioCodecOf("dts", "DTS-HD MA")).toBe("DTS-HD MA");
|
||||
expect(channelsLabel(8)).toBe("7.1");
|
||||
expect(channelsLabel(undefined, "stereo")).toBe("2.0");
|
||||
expect(languageOf("und")).toBeUndefined();
|
||||
expect(languageOf("jpn")).toBe("ja");
|
||||
expect(sidecarLanguageOf("x.zh-Hant.ass")).toBe("zh-Hant");
|
||||
});
|
||||
|
||||
it("写回 delivery 与读回互为逆;旧口径返回 null", () => {
|
||||
const s = versionSpecOf({ path: DV_FILE, probe: PROBE_DV });
|
||||
const d = deliveryOfSpec(s);
|
||||
expect(d.specVersion).toBe(VERSION_SPEC_VERSION);
|
||||
expect(d.sourceType).toBe("WEB-DL");
|
||||
expect(d.audioCodecs).toEqual(["DDP Atmos", "AAC"]);
|
||||
expect(specFromDelivery(JSON.parse(JSON.stringify(d)))).toEqual(s);
|
||||
expect(specFromDelivery({ resolution: "2160p", sourceType: "WEB-DL" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("汇总", () => {
|
||||
it("按 分辨率 + 来源 + HDR 分组,DV 带 HDR10 兜底只算 DV", () => {
|
||||
const dv = versionSpecOf({ path: DV_FILE, probe: PROBE_DV });
|
||||
const bd = versionSpecOf({ path: BD_FILE });
|
||||
const sum = summarizeVersions([
|
||||
{ spec: dv, sizeBytes: 14.2 * GB },
|
||||
{ spec: dv, sizeBytes: 13 * GB },
|
||||
{ spec: bd, sizeBytes: 8 * GB },
|
||||
]);
|
||||
expect(sum.versionCount).toBe(3);
|
||||
expect(sum.sizeBytes).toBe(35.2 * GB);
|
||||
expect(sum.specs).toEqual([
|
||||
{ resolution: "2160p", source: "WEB-DL", hdr: ["DV"], count: 2 },
|
||||
{ resolution: "1080p", source: "BluRay", hdr: [], count: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("与版本目标比对", () => {
|
||||
const v = (id: string, path: string, probe?: ProbeLike, size = 10 * GB): LocalVersion => ({
|
||||
assetId: id,
|
||||
path,
|
||||
spec: versionSpecOf({ path, probe: probe ?? null }),
|
||||
sizeBytes: size,
|
||||
unitCount: 1,
|
||||
});
|
||||
const target: SlotSpec = {
|
||||
id: "slot-4k",
|
||||
name: "4K",
|
||||
hard: { resolutions: ["2160p"], minSeeders: 5, sites: ["only-site"], include: ["FLUX"], maxSizeBytes: 1 },
|
||||
};
|
||||
|
||||
it("没有版本 = missing", () => {
|
||||
expect(matchVersions(target, [], { isEpisode: true })).toEqual({
|
||||
slotId: "slot-4k",
|
||||
state: "missing",
|
||||
assetId: null,
|
||||
reasons: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("两个版本里有一个达标 = satisfied,指向达标的那个;站点/做种/关键词/整包体积条件不参与", () => {
|
||||
const m = matchVersions(target, [v("bd", BD_FILE), v("dv", DV_FILE, PROBE_DV)], { isEpisode: true });
|
||||
expect(m).toMatchObject({ state: "satisfied", assetId: "dv" });
|
||||
});
|
||||
|
||||
it("都不达标 = upgradeable,附不达标原因", () => {
|
||||
const m = matchVersions(target, [v("bd", BD_FILE)], { isEpisode: true });
|
||||
expect(m.state).toBe("upgradeable");
|
||||
expect(m.assetId).toBe("bd");
|
||||
expect(m.reasons.join(" ")).toMatch(/1080p/);
|
||||
});
|
||||
|
||||
it("达标但升级策略还有余量 = upgradeable;到顶 = satisfied", () => {
|
||||
const up: SlotSpec = { id: "s", name: "x", hard: {}, upgrade: { enabled: true } };
|
||||
const web = matchVersions(up, [v("dv", DV_FILE, PROBE_DV)], { isEpisode: true });
|
||||
expect(web).toMatchObject({ state: "upgradeable", assetId: "dv", reasons: ["来源还能升级"] });
|
||||
const capped: SlotSpec = { ...up, upgrade: { enabled: true, ceilingTier: 2 } };
|
||||
expect(matchVersions(capped, [v("dv", DV_FILE, PROBE_DV)], { isEpisode: true }).state).toBe("satisfied");
|
||||
const onlyWeb: SlotSpec = { ...up, hard: { sources: ["WEB-DL"] } };
|
||||
expect(matchVersions(onlyWeb, [v("dv", DV_FILE, PROBE_DV)], { isEpisode: true }).state).toBe("satisfied");
|
||||
});
|
||||
|
||||
it("只凭文件名时字幕未知不判不达标;探测过且缺中字则不达标", () => {
|
||||
const needZh: SlotSpec = { id: "z", name: "中字", hard: { requiredSubtitles: ["zh"] } };
|
||||
expect(matchVersions(needZh, [v("bd", BD_FILE)], { isEpisode: true }).state).toBe("satisfied");
|
||||
const noZh: ProbeLike = { ...PROBE_DV, subtitles: [{ language: "eng" }] };
|
||||
expect(matchVersions(needZh, [v("dv", DV_FILE, noZh)], { isEpisode: true }).state).toBe("upgradeable");
|
||||
});
|
||||
|
||||
it("电影保留整片体积条件", () => {
|
||||
const small: SlotSpec = { id: "m", name: "m", hard: { maxSizeBytes: 5 * GB } };
|
||||
const movie = "Oppenheimer.2023.2160p.UHD.BluRay.REMUX.HDR10.HEVC.TrueHD.7.1.Atmos-FraMeSToR.mkv";
|
||||
expect(matchVersions(small, [v("r", movie, undefined, 80 * GB)], { isEpisode: false }).state).toBe("upgradeable");
|
||||
expect(matchVersions(small, [v("r", movie, undefined, 80 * GB)], { isEpisode: true }).state).toBe("satisfied");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// LIBV-1 回填已入库视频资产的版本规格(media_asset.delivery)。默认只读(dry-run),加 --apply 才改库。
|
||||
//
|
||||
// 用法(DATABASE_URL 必须显式给出,脚本不带默认库):
|
||||
// DATABASE_URL=… deno run -A scripts/asset-spec-backfill.ts # 看清单:哪些资产会更新、前后规格
|
||||
// DATABASE_URL=… deno run -A scripts/asset-spec-backfill.ts --entity <作品 id> # 只看一部作品
|
||||
// DATABASE_URL=… deno run -A scripts/asset-spec-backfill.ts --probe # 对缺探测结果的本地文件跑 ffprobe(只读文件)
|
||||
// DATABASE_URL=… deno run -A scripts/asset-spec-backfill.ts --apply # 写库
|
||||
// --json 输出 JSON;--limit N 只处理前 N 个资产
|
||||
//
|
||||
// 规格来源:库内文件名解析 + 已有 media_info.probe + 入库时记下的资源解析(media_info._umt.quality)。
|
||||
// 不请求任何站点;--probe 只读库根里的本地文件。写回只合并 delivery 的键,可重复执行。
|
||||
|
||||
import { ffprobe } from "@umt/adapters";
|
||||
import { createDb } from "@umt/db";
|
||||
import { backfillVersionSpecs } from "../apps/server/src/modules/acquisition-organize/services/version-backfill.ts";
|
||||
|
||||
const argv = [...Deno.args];
|
||||
const APPLY = argv.includes("--apply");
|
||||
if (APPLY && argv.includes("--dry-run")) {
|
||||
console.error("--apply 与 --dry-run 不能同时给出");
|
||||
Deno.exit(2);
|
||||
}
|
||||
const arg = (k: string) => {
|
||||
const i = argv.indexOf(k);
|
||||
return i >= 0 ? argv[i + 1] : undefined;
|
||||
};
|
||||
const url = Deno.env.get("DATABASE_URL");
|
||||
if (!url) {
|
||||
console.error("需要 DATABASE_URL(脚本不带默认库)");
|
||||
Deno.exit(2);
|
||||
}
|
||||
const db = createDb({ connectionString: url, max: 2 });
|
||||
try {
|
||||
const report = await backfillVersionSpecs(db, {
|
||||
apply: APPLY,
|
||||
entityId: arg("--entity"),
|
||||
limit: arg("--limit") ? Number(arg("--limit")) : undefined,
|
||||
probe: argv.includes("--probe") ? (p) => ffprobe(p, { timeoutMs: 30_000 }) : undefined,
|
||||
});
|
||||
if (argv.includes("--json")) console.log(JSON.stringify(report, null, 2));
|
||||
else {
|
||||
console.log(`# asset-spec-backfill (${APPLY ? "APPLY" : "DRY-RUN"})\n`);
|
||||
console.log(
|
||||
`扫描 ${report.scanned} 个视频资产:已是当前口径 ${report.upToDate},${APPLY ? "已更新" : "将更新"} ${report.changed}` +
|
||||
`,本次探测 ${report.probed},无库内位置 ${report.noLocation}\n`,
|
||||
);
|
||||
for (const it of report.items) {
|
||||
console.log(`- [${it.reason}] ${it.entityTitle ?? "(无作品)"} · ${it.path ?? it.assetId}`);
|
||||
console.log(` 前:${it.before}`);
|
||||
console.log(` 后:${it.after}(来源 ${it.specFrom.join("+") || "无"})`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
Reference in new issue
Block a user