feat(library): show stored versions per episode and movie (LIBV-1)
- domain versionSpecOf: file name + ffprobe + ingest quality -> version spec;
summarizeVersions; matchVersions (satisfied / missing / upgradeable, single target)
- GET /library/{id}/versions: per-season/episode versions, summaries, matches[]
- organize writes full spec into media_asset.delivery (specVersion 1)
- scripts/asset-spec-backfill.ts: dry-run by default, --probe local only, --apply
- web: season summary line, one-line spec per episode, N versions expand,
tech info details (TODO: DEAI-2 TechInfo), movie versions panel
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
3763c86481
commit
bf9e73e86e
19 files changed
+2060
-9
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,15 +1441,18 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
...(probe ? { probe } : {}),
|
||||
_umt: { quality: fx?.quality ?? {}, unitKeys: fx?.unitKeys ?? [] },
|
||||
}),
|
||||
// LIBV-1:库内文件名 + 探测 + 资源解析合成版本规格
|
||||
delivery: jsonb(
|
||||
fx
|
||||
? {
|
||||
...(fx.quality.resolution ? { resolution: fx.quality.resolution } : {}),
|
||||
...(fx.quality.source ? { sourceType: fx.quality.source } : {}),
|
||||
...(fx.quality.releaseGroup ? { releaseGroup: fx.quality.releaseGroup.name } : {}),
|
||||
...(fx.quality.hdr?.length ? { hdr: fx.quality.hdr } : {}),
|
||||
}
|
||||
: {},
|
||||
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 } : {}),
|
||||
...(fx.quality.releaseGroup ? { releaseGroup: fx.quality.releaseGroup.name } : {}),
|
||||
...(fx.quality.hdr?.length ? { hdr: fx.quality.hdr } : {}),
|
||||
}
|
||||
: {},
|
||||
),
|
||||
content_edition: fx?.quality.cut ?? null,
|
||||
source_release_id: dt?.source_release_id ?? null,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user