- adapters/meta:bgm.tv 官方 API(条目 / 搜索 / 每日放送)与豆瓣移动版接口(条目 / 实时热门) - 识别升级链:豆瓣条目缓存没有时在线查;AniBT 的 bgm ID、公开 BT 站标题搜 bgm → 名称 + 年份匹配 TMDB(季条目去季名与年份) - 条目缓存 30 天,查不到 6 小时负缓存;测试进程缺省不出外网 - 发现页:bgm 每日放送、豆瓣热门电影 / 剧集,点条目匹配 TMDB 作品 Co-Authored-By: Claude Opus 5.5 <[email protected]>
138 lines
4.8 KiB
TypeScript
138 lines
4.8 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;
|
|
bangumiId?: 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 } : {}),
|
|
...(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,
|
|
};
|
|
}
|