Files
UHD_Media_Tool/packages/mapping/src/site-release.ts
T
rootandClaude Opus 5.5 42ecc93fa5 feat(mapping): centralize domain <-> contracts mappings (B5)
New @umt/mapping (depends on domain + contracts only): download status + pause reason,
evidence level / media kind, identity resolution status, slot status / spec columns /
evaluation, ParsedRelease <-> source_release.parsed, SiteRelease <-> source_release,
PathBinding, OrganizePlan -> organization_record / file results / organize.plan output.
Tables are Record<union, ...> so adding a value on either side fails typecheck or tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 19:20:30 +08:00

136 lines
4.7 KiB
TypeScript

import type { JsonObject, SourceRelease, SourceReleaseCreate } from "@umt/contracts";
import type { ParsedRelease } from "@umt/domain";
import { toParsedColumns } from "./release.ts";
// 站点资源(adapters 的 SiteRelease)↔ source_release。
// mapping 不依赖 adapters:这里用结构相同的 SiteReleaseLike,adapters 的 SiteRelease 可直接传入
// (apps/server/test/mapping-compat.test.ts 在类型层校验两者兼容)。
export type SitePromotionType = "none" | "free" | "2xfree" | "50%" | "2x50%" | "30%" | "2x" | "unknown";
export interface SiteReleaseLike {
/** 适配器侧的站点标识(可能是站点 key);落库时用 site 行 UUID 覆盖 */
siteId: string;
siteReleaseId: string;
title: string;
subtitle?: string;
category: { id: string | null; name: string | null };
sizeBytes: number | null;
seeders: number | null;
leechers: number | null;
snatched: number | null;
promotion: {
type: SitePromotionType;
downloadFactor: number | null;
uploadFactor: number | null;
expiresAt: string | null;
};
hr: boolean | "unknown";
uploadedAt: string | null;
detailUrl: string;
downloadUrl?: string;
infoHash?: string;
imdbId?: string;
doubanId?: string;
tags: string[];
raw: Record<string, unknown>;
observedAt: string;
}
const HEX40 = /^[0-9a-f]{40}$/;
const HEX64 = /^[0-9a-f]{64}$/;
const PROMOTION_TYPES: readonly SitePromotionType[] = [
"none",
"free",
"2xfree",
"50%",
"2x50%",
"30%",
"2x",
"unknown",
];
/**
* SiteRelease → source_release 新建/upsert 输入(按 siteId + siteReleaseId upsert)。
* - siteId 取 site 行 UUID(opts.siteId),不是适配器里的站点 key;
* - downloadUrl 不入库(可能需要 cookie 或含一次性令牌,下载时现场向适配器取);
* - H&R、完成数、详情页、外部 ID 等无专门列的字段放进 raw._site。
*/
export function siteReleaseToSourceRelease(
r: SiteReleaseLike,
opts: { siteId: string; parsed?: ParsedRelease },
): SourceReleaseCreate {
const hash = r.infoHash?.toLowerCase();
const site: JsonObject = {
adapterSiteId: r.siteId,
categoryName: r.category.name,
hr: r.hr,
snatched: r.snatched,
detailUrl: r.detailUrl,
...(r.imdbId ? { imdbId: r.imdbId } : {}),
...(r.doubanId ? { doubanId: r.doubanId } : {}),
};
return {
siteId: opts.siteId,
siteReleaseId: r.siteReleaseId,
title: r.title,
subtitle: r.subtitle ?? null,
categoryRaw: r.category.id ?? r.category.name,
tagsRaw: [...r.tags],
sizeBytes: r.sizeBytes,
infohashV1: hash && HEX40.test(hash) ? hash : null,
infohashV2: hash && HEX64.test(hash) ? hash : null,
promotion: {
kind: r.promotion.type,
observedAt: r.observedAt,
expiresAt: r.promotion.expiresAt,
raw: { downloadFactor: r.promotion.downloadFactor, uploadFactor: r.promotion.uploadFactor },
},
promotionExpiresAt: r.promotion.expiresAt,
seeders: r.seeders,
leechers: r.leechers,
sourcePublishedAt: r.uploadedAt,
...(opts.parsed ? toParsedColumns(opts.parsed) : {}),
raw: { ...r.raw, _site: site },
};
}
/** source_release 行 → SiteReleaseLike(站点工作台从本地快照展示时用);缺失字段按"未知"还原 */
export function sourceReleaseToSiteRelease(row: SourceRelease): SiteReleaseLike {
const { _site, ...raw } = row.raw as JsonObject & { _site?: JsonObject };
const site = _site ?? {};
const promoRaw = (row.promotion.raw ?? {}) as { downloadFactor?: number | null; uploadFactor?: number | null };
const kind = row.promotion.kind as SitePromotionType | undefined;
const str = (v: unknown) => (typeof v === "string" ? v : undefined);
const hr = site.hr === true || site.hr === false ? site.hr : "unknown";
const imdbId = str(site.imdbId);
const doubanId = str(site.doubanId);
const infoHash = row.infohashV1 ?? row.infohashV2 ?? undefined;
return {
siteId: str(site.adapterSiteId) ?? row.siteId,
siteReleaseId: row.siteReleaseId,
title: row.title,
...(row.subtitle !== null ? { subtitle: row.subtitle } : {}),
category: { id: row.categoryRaw, name: str(site.categoryName) ?? null },
sizeBytes: row.sizeBytes,
seeders: row.seeders,
leechers: row.leechers,
snatched: typeof site.snatched === "number" ? site.snatched : null,
promotion: {
type: kind && PROMOTION_TYPES.includes(kind) ? kind : "unknown",
downloadFactor: promoRaw.downloadFactor ?? null,
uploadFactor: promoRaw.uploadFactor ?? null,
expiresAt: row.promotionExpiresAt,
},
hr,
uploadedAt: row.sourcePublishedAt,
detailUrl: str(site.detailUrl) ?? "",
...(infoHash ? { infoHash } : {}),
...(imdbId ? { imdbId } : {}),
...(doubanId ? { doubanId } : {}),
tags: [...row.tagsRaw],
raw,
observedAt: row.lastSeenAt,
};
}