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; bangumiId?: string; tags: string[]; raw: Record; 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 } : {}), ...(r.bangumiId ? { bangumiId: r.bangumiId } : {}), }; 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, }; }