- 标题区:海报、作品名 (年份)、站点;资源行显示原始标题、促销、H&R、做种 / 下载、大小、发布时间 - 下载器与保存目录并排下拉,缺省「自动」,下方写明「将保存到」的实际路径;保留「自定义路径」 - 作品身份折叠区:类型 + TMDB 编号(可搜索作品),未识别时默认展开,指定后随下载绑定 - 已有版本先显示一行摘要,选一次性洗版才展开勾选列表 - 搜索、工作台、原站视图三处调用方补传资源字段 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
129 lines
5.4 KiB
TypeScript
129 lines
5.4 KiB
TypeScript
import type {
|
||
AssetVersion,
|
||
DownloadSavePaths,
|
||
DownloadSaveTarget,
|
||
LibraryVersions,
|
||
ManualAcquisitionRequest,
|
||
VersionSummary,
|
||
} from "@umt/contracts";
|
||
import { formatBytes } from "~/lib/utils.ts";
|
||
|
||
// 手动下载对话框的纯逻辑:保存目录解析、已有版本摘要、提交参数。界面在 manual-download-dialog.tsx。
|
||
|
||
export type ManualDownloadOptions = Omit<ManualAcquisitionRequest, "sourceReleaseId">;
|
||
export type TmdbType = "movie" | "tv";
|
||
|
||
/** 保存目录下拉的取值:"" = 自动,CUSTOM_DIR = 自定义路径,其余是目录本身 */
|
||
export const CUSTOM_DIR = "__custom__";
|
||
|
||
/** 选中的下载器;没选时用「自动」会用的那个 */
|
||
export function targetOf(paths: DownloadSavePaths | undefined, instanceId: string): DownloadSaveTarget | null {
|
||
if (!paths) return null;
|
||
const id = instanceId || paths.autoInstanceId;
|
||
return paths.instances.find((t) => t.instanceId === id) ?? null;
|
||
}
|
||
|
||
/** 「将保存到」显示的路径:自动 = 下载器默认保存目录;自定义取输入框;没有可用下载器为 null */
|
||
export function resolveSavePath(
|
||
target: DownloadSaveTarget | null,
|
||
dir: string,
|
||
custom: string,
|
||
): { path: string; hostPath: string | null } | null {
|
||
if (dir === CUSTOM_DIR) {
|
||
const p = custom.trim();
|
||
return p ? { path: p, hostPath: null } : null;
|
||
}
|
||
if (!target) return null;
|
||
const hit = dir ? target.dirs.find((d) => d.path === dir) : target.dirs[0];
|
||
return hit ? { path: hit.path, hostPath: hit.hostPath } : null;
|
||
}
|
||
|
||
/** 本机路径与下载器路径不同时括注本机路径,方便对照文件管理 */
|
||
export function savePathText(r: { path: string; hostPath: string | null }): string {
|
||
return r.hostPath && r.hostPath !== r.path ? `${r.path}(本机 ${r.hostPath})` : r.path;
|
||
}
|
||
|
||
/** 已有版本去重展开(电影 / 每集 / 未对上集的) */
|
||
export function existingVersions(inv: LibraryVersions | undefined): AssetVersion[] {
|
||
const out = new Map<string, AssetVersion>();
|
||
for (const v of inv?.movie?.versions ?? []) out.set(v.assetId, v);
|
||
for (const season of inv?.seasons ?? [])
|
||
for (const episode of season.episodes) for (const v of episode.versions) out.set(v.assetId, v);
|
||
for (const v of inv?.unplaced ?? []) out.set(v.assetId, v);
|
||
return [...out.values()];
|
||
}
|
||
|
||
/** 一行摘要:「媒体库已有:1080p WEB-DL · 8.9 GB」;多种规格只列前两种 */
|
||
export function versionSummaryText(s: VersionSummary | undefined): string | null {
|
||
if (!s?.versionCount) return null;
|
||
const specs = s.specs.map((g) => [g.resolution, g.source, ...g.hdr].filter(Boolean).join(" ")).filter(Boolean);
|
||
const head = specs.slice(0, 2).join("、") + (specs.length > 2 ? ` 等 ${specs.length} 种` : "");
|
||
const parts = [
|
||
head,
|
||
s.versionCount > 1 ? `${s.versionCount} 个版本` : "",
|
||
s.sizeBytes ? formatBytes(s.sizeBytes, 1) : "",
|
||
];
|
||
return `媒体库已有:${parts.filter(Boolean).join(" · ")}`;
|
||
}
|
||
|
||
/** 下载前指定的作品:本地作品,或按类型 + TMDB 编号 */
|
||
export type IdentityChoice =
|
||
| { kind: "entity"; id: string; title: string }
|
||
| { kind: "tmdb"; type: TmdbType; id: string };
|
||
|
||
/** 作品当前的 TMDB 身份;和它相同就不算改动 */
|
||
export function identityChanged(
|
||
choice: IdentityChoice | null,
|
||
current: { entityId?: string | undefined; tmdb?: { type: TmdbType; id: string } | null },
|
||
): boolean {
|
||
if (!choice) return false;
|
||
if (choice.kind === "entity") return choice.id !== current.entityId;
|
||
if (!/^\d+$/.test(choice.id)) return false;
|
||
return !(current.tmdb && current.tmdb.type === choice.type && current.tmdb.id === choice.id);
|
||
}
|
||
|
||
export function buildManualOptions(s: {
|
||
entityId?: string | undefined;
|
||
currentTmdb?: { type: TmdbType; id: string } | null;
|
||
identity: IdentityChoice | null;
|
||
instanceId: string;
|
||
target: DownloadSaveTarget | null;
|
||
dir: string;
|
||
custom: string;
|
||
allowUnrecognized: boolean;
|
||
mode: "normal" | "replace";
|
||
selected: string[];
|
||
}): ManualDownloadOptions {
|
||
const changed = identityChanged(s.identity, { entityId: s.entityId, tmdb: s.currentTmdb ?? null });
|
||
const identity: ManualDownloadOptions["identity"] =
|
||
changed && s.identity
|
||
? s.identity.kind === "entity"
|
||
? { mediaEntityId: s.identity.id }
|
||
: { external: { provider: "tmdb", entityType: s.identity.type, externalId: s.identity.id } }
|
||
: undefined;
|
||
// 改了作品时已有版本属于旧作品,只能正常入库
|
||
const mode = identity ? "normal" : s.mode;
|
||
const savePath = s.dir === CUSTOM_DIR ? s.custom.trim() : s.dir;
|
||
// 自动下载器 + 指定目录:钉住解析出的那个下载器,目录才对得上
|
||
const instanceId = s.instanceId || (savePath && s.dir !== CUSTOM_DIR ? (s.target?.instanceId ?? "") : "");
|
||
return {
|
||
...(identity ? { identity } : s.entityId ? { entityId: s.entityId } : {}),
|
||
...(instanceId ? { downloaderInstanceId: instanceId } : {}),
|
||
...(savePath ? { savePath } : {}),
|
||
...(s.allowUnrecognized && !identity ? { allowUnrecognized: true } : {}),
|
||
importMode: mode,
|
||
replacementAssetIds: mode === "replace" ? s.selected : [],
|
||
};
|
||
}
|
||
|
||
/** 从标题解析结果猜类型:有季或集就是剧集 */
|
||
export function kindHintOf(parsed: unknown): TmdbType {
|
||
const p = (parsed ?? {}) as { season?: unknown; episodes?: unknown };
|
||
return (p.season !== undefined && p.season !== null) || (p.episodes !== undefined && p.episodes !== null)
|
||
? "tv"
|
||
: "movie";
|
||
}
|
||
|
||
/** 作品类型 → TMDB 类型 */
|
||
export const tmdbTypeOf = (kind: string | undefined): TmdbType => (kind === "movie" ? "movie" : "tv");
|