diff --git a/apps/server/src/modules/acquisition-organize/__tests__/library-versions.test.ts b/apps/server/src/modules/acquisition-organize/__tests__/library-versions.test.ts new file mode 100644 index 00000000..ef844c30 --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/__tests__/library-versions.test.ts @@ -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, + 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 = {}; + + 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" }); + }); +}); diff --git a/apps/server/src/modules/acquisition-organize/routes/library-versions.ts b/apps/server/src/modules/acquisition-organize/routes/library-versions.ts new file mode 100644 index 00000000..8e3d285b --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/routes/library-versions.ts @@ -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" }, + ); +} diff --git a/apps/server/src/modules/acquisition-organize/routes/library.ts b/apps/server/src/modules/acquisition-organize/routes/library.ts index 7950c4fc..a523f5bf 100644 --- a/apps/server/src/modules/acquisition-organize/routes/library.ts +++ b/apps/server/src/modules/acquisition-organize/routes/library.ts @@ -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, diff --git a/apps/server/src/modules/acquisition-organize/services/library-versions.ts b/apps/server/src/modules/acquisition-organize/services/library-versions.ts new file mode 100644 index 00000000..b45bcd34 --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/services/library-versions.ts @@ -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 | null { + const p = (mediaInfo as { probe?: unknown } | null)?.probe; + return p && typeof p === "object" ? (p as Record) : 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 { + const spec = slotSpecFromRow(toApi(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 { + 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(); + for (const l of locs as LocRow[]) locsOf.set(l.media_asset_id, [...(locsOf.get(l.media_asset_id) ?? []), l]); + const unitsOfAsset = new Map(); + 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(); + 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(); + 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(); + 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), + }; +} diff --git a/apps/server/src/modules/acquisition-organize/services/organize.ts b/apps/server/src/modules/acquisition-organize/services/organize.ts index 0f8483df..8385b9f6 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize.ts @@ -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 Promise; +} + +/** 一行可读的规格,dry-run 清单用 */ +export function specLine(d: unknown): string { + const o = (d ?? {}) as Record; + 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 { + 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(); + 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; + 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; +} diff --git a/apps/web/app/api/library-versions.ts b/apps/web/app/api/library-versions.ts new file mode 100644 index 00000000..3c91bfb1 --- /dev/null +++ b/apps/web/app/api/library-versions.ts @@ -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), + }); +} diff --git a/apps/web/app/components/library/versions.tsx b/apps/web/app/components/library/versions.tsx new file mode 100644 index 00000000..586d7e2d --- /dev/null +++ b/apps/web/app/components/library/versions.tsx @@ -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 = { + REMUX: "Remux", + "BluRay-Disc": "原盘", + BluRay: "蓝光压制", +}; +const CODEC_LABEL: Record = { "H.265": "HEVC" }; +const LANG_LABEL: Record = { + "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 = { 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 ? ( + + 查看下载任务 + + ) : null, + }, + { label: "规格来源", value: v.specFrom.map((f) => SPEC_FROM[f] ?? f).join(" + ") || null }, + ].filter((it) => it.value !== null && it.value !== undefined && it.value !== ""); + return ( +
+ + 技术信息 + + +
+ + {v.probe ? ( +
+ + 探测原始值 + +
+              {JSON.stringify(v.probe, null, 2)}
+            
+
+ ) : null} +
+
+ ); +} + +/** 一个版本:规格一行 + 技术信息折叠 */ +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 ( +
+

+ {parts.length ? ( + parts.map((p, i) => ( + + {i > 0 ? · : null} + {p} + + )) + ) : ( + {v.fileName} + )} + {best ? 符合收录版本 : null} + {missing ? 文件不在原位置 : null} + {aside} +

+ +
+ ); +} + +/** 一集的版本:一个时直接一行;多个时先显示达标(或第一个)版本,「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 ? ( +

+ 可升级 + {upgrade.reasons.length ? :{upgrade.reasons.join(";")} : null} +

+ ) : null; + if (versions.length === 1) + return ( +
+ + {note} +
+ ); + const rest = versions.filter((v) => v !== lead); + const tagBest = match?.state === "satisfied"; + const toggle = ( + + ); + return ( +
+ + {note} + {open ? ( +
    + {rest.map((v) => ( +
  • + +
  • + ))} +
+ ) : null} +
+ ); +} + +/** 电影的入库版本面板:每个文件一行,附与收录版本的比对 */ +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 ( + +
    + {versions.map((v) => ( +
  • + 1 && match?.state === "satisfied" && v.assetId === match.assetId} + /> +
  • + ))} +
+
+ ); +} + +/** 剧集里没对上具体集数的文件 */ +export function UnplacedVersions({ versions }: { versions: AssetVersion[] }) { + const [open, setOpen] = useState(false); + if (!versions.length) return null; + return ( +
+ + {open ? ( +
    + {versions.map((v) => ( +
  • + +
  • + ))} +
+ ) : null} +
+ ); +} diff --git a/apps/web/app/components/media/media-detail.tsx b/apps/web/app/components/media/media-detail.tsx index 8f897351..07147730 100644 --- a/apps/web/app/components/media/media-detail.tsx +++ b/apps/web/app/components/media/media-detail.tsx @@ -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 ( -
  • +
  • @@ -331,6 +333,7 @@ export function EpisodeRow({ e }: { e: EpisodeRowData }) {

    {meta.join(" · ")}

    {e.overview ? : null} + {e.extra}
  • ); @@ -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(" · ") || "—"}

    + {s.detail ? ( +

    {s.detail}

    + ) : null} {s.summary ?
    {s.summary}
    : null} 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: ( = 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 ? ( + + ) : null; + })(), }} /> ); @@ -792,6 +821,7 @@ function EpisodesPanel({ detail, units }: { detail: LibraryDetail; units: Conten })} )} + !o && setEditing(null)} /> ); @@ -1056,6 +1086,7 @@ export default function LibraryDetailPage() { ) : null} {e.kind !== "movie" ? : null} + {e.kind === "movie" ? : null}
    diff --git a/docs/plan/00-first-version-plan.md b/docs/plan/00-first-version-plan.md index 0d8186e5..d8072025 100644 --- a/docs/plan/00-first-version-plan.md +++ b/docs/plan/00-first-version-plan.md @@ -177,3 +177,4 @@ NFO / 图片投影、身份巡检与挂载根因聚合、HTTP 失败自动切浏 - **搜索快速筛选(SEARCH-3,用户 2026-09-29,对照 MP)**:搜索结果与站点资源列表顶部多选筛选条:分辨率、编码、版本类型(原盘/Remux/蓝光压制/WEB-DL/HDTV)、站点、促销、HDR/DV、制作组、季集;选项从当前结果聚合并显示计数,可组合、可清除、记住上次选择。 - **计划页与智能体(用户 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 合入后统一)、电影「入库版本」面板。 diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index a4db168d..a1ddfc48 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -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", diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index 117fa7ad..8fcb4eaf 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -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; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0fccabdb..830fc75d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -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"; diff --git a/packages/contracts/src/library-versions.ts b/packages/contracts/src/library-versions.ts new file mode 100644 index 00000000..bc6c0b34 --- /dev/null +++ b/packages/contracts/src/library-versions.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; + +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; + +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; + +export const UnitMatchState = z.enum(["satisfied", "missing", "upgradeable"]); +export type UnitMatchState = z.infer; +export const UnitMatch = z.object({ + slotId: Id.nullable(), + state: UnitMatchState, + assetId: Id.nullable(), + reasons: z.array(z.string()), +}); +export type UnitMatch = z.infer; +export const MatchCounts = z.object({ satisfied: NonNegInt, missing: NonNegInt, upgradeable: NonNegInt }); +export type MatchCounts = z.infer; + +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; + +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; + +export const LibraryVersionsQuery = z.object({ + /** 比对哪个版本目标;缺省取作品的主版本目标 */ + slotId: z.uuid().optional(), +}); +export type LibraryVersionsQuery = z.infer; + +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; diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 24657bd2..daa1d66a 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -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"; diff --git a/packages/domain/src/library/versions.ts b/packages/domain/src/library/versions.ts new file mode 100644 index 00000000..c6d165a7 --- /dev/null +++ b/packages/domain/src/library/versions.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 = { + 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 = { 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 = { + 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 = (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 { + 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; + if (o.specVersion !== VERSION_SPEC_VERSION) return null; + const str = (k: string) => (typeof o[k] === "string" ? (o[k] as string) : undefined); + const arr = (k: string) => (Array.isArray(o[k]) ? (o[k] as T[]) : []); + const spec: VersionSpec = { + hdr: sortHdr(arr("hdr")), + audio: arr("audio"), + subtitleLanguages: arr("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; sizeBytes: number | null }[], +): VersionSummary { + const groups = new Map(); + 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: [] }; +} diff --git a/packages/domain/test/library-versions.test.ts b/packages/domain/test/library-versions.test.ts new file mode 100644 index 00000000..fc572906 --- /dev/null +++ b/packages/domain/test/library-versions.test.ts @@ -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"); + }); +}); diff --git a/scripts/asset-spec-backfill.ts b/scripts/asset-spec-backfill.ts new file mode 100644 index 00000000..e1c5643f --- /dev/null +++ b/scripts/asset-spec-backfill.ts @@ -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(); +}