feat(下载对话框): 学 MoviePilot 重做手动下载对话框

- 标题区:海报、作品名 (年份)、站点;资源行显示原始标题、促销、H&R、做种 / 下载、大小、发布时间
- 下载器与保存目录并排下拉,缺省「自动」,下方写明「将保存到」的实际路径;保留「自定义路径」
- 作品身份折叠区:类型 + TMDB 编号(可搜索作品),未识别时默认展开,指定后随下载绑定
- 已有版本先显示一行摘要,选一次性洗版才展开勾选列表
- 搜索、工作台、原站视图三处调用方补传资源字段

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 12:10:03 +08:00
1 parent 4bb19433ad
commit 6e645bce8f
7 files changed
+1012 -117

No files matched your search

@@ -0,0 +1,128 @@
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");