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,274 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { AssetVersion, DownloadSavePaths, DownloadSaveTarget } from "@umt/contracts";
import { createElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { ExistingVersionsSection, IdentitySection, SaveTargetFields } from "./manual-download-dialog.tsx";
import {
buildManualOptions,
CUSTOM_DIR,
kindHintOf,
resolveSavePath,
savePathText,
targetOf,
versionSummaryText,
} from "./manual-download-model.ts";
// 手动下载对话框:保存目录解析与显示、未识别时身份区展开并随提交带上身份、已有版本摘要与洗版展开。
const A = "11111111-1111-4111-8111-111111111111";
const B = "22222222-2222-4222-8222-222222222222";
const E = "33333333-3333-4333-8333-333333333333";
const paths: DownloadSavePaths = {
autoInstanceId: B,
instances: [
{
instanceId: A,
name: "qb-a",
isDefault: false,
dirs: [{ path: "/downloads", hostPath: "/downloads", isDefault: true }],
},
{
instanceId: B,
name: "qb-main",
isDefault: true,
dirs: [
{ path: "/data/downloads", hostPath: "/mnt/dl", isDefault: true },
{ path: "/data/tv", hostPath: "/mnt/tv", isDefault: false },
],
},
],
};
const render = (node: ReactNode) =>
renderToStaticMarkup(createElement(QueryClientProvider, { client: new QueryClient() }, node));
const base = {
entityId: undefined,
currentTmdb: null,
identity: null,
instanceId: "",
target: targetOf(paths, ""),
dir: "",
custom: "",
allowUnrecognized: false,
mode: "normal" as const,
selected: [],
};
describe("保存目录", () => {
it("自动:解析出自动下载器的默认保存目录;本机路径不同时括注", () => {
const t = targetOf(paths, "");
expect(t?.instanceId).toBe(B);
const r = resolveSavePath(t, "", "");
expect(r).toEqual({ path: "/data/downloads", hostPath: "/mnt/dl" });
expect(savePathText(r!)).toBe("/data/downloads(本机 /mnt/dl)");
expect(resolveSavePath(targetOf(paths, A), "", "")).toEqual({ path: "/downloads", hostPath: "/downloads" });
expect(savePathText({ path: "/downloads", hostPath: "/downloads" })).toBe("/downloads");
});
it("选目录 / 自定义路径;自动下载器 + 指定目录时钉住解析出的下载器", () => {
const t = targetOf(paths, "");
expect(resolveSavePath(t, "/data/tv", "")).toEqual({ path: "/data/tv", hostPath: "/mnt/tv" });
expect(resolveSavePath(t, CUSTOM_DIR, " ")).toBeNull();
expect(resolveSavePath(t, CUSTOM_DIR, " /x/y ")).toEqual({ path: "/x/y", hostPath: null });
expect(buildManualOptions({ ...base, dir: "/data/tv" })).toMatchObject({
downloaderInstanceId: B,
savePath: "/data/tv",
});
const auto = buildManualOptions(base);
expect(auto).not.toHaveProperty("savePath");
expect(auto).not.toHaveProperty("downloaderInstanceId");
const custom = buildManualOptions({ ...base, dir: CUSTOM_DIR, custom: " /x/y " });
expect(custom).toMatchObject({ savePath: "/x/y" });
expect(custom).not.toHaveProperty("downloaderInstanceId");
});
it("界面:并排两个下拉,下方写明将保存到哪;自定义时展开输入框", () => {
const props = {
formId: "f",
instances: paths.instances,
autoInstanceId: B,
instanceId: "",
onInstanceChange: () => {},
target: targetOf(paths, "") as DownloadSaveTarget,
dir: "",
onDirChange: () => {},
custom: "",
onCustomChange: () => {},
};
const html = render(createElement(SaveTargetFields, props));
expect(html).toContain("下载器");
expect(html).toContain("自动:qb-main");
expect(html).toContain("将保存到:/data/downloads(本机 /mnt/dl)");
expect(html).not.toContain("自定义路径</label>");
const custom = render(createElement(SaveTargetFields, { ...props, dir: CUSTOM_DIR, custom: "/x" }));
expect(custom).toContain("将保存到:/x");
expect(custom).toContain('id="f-custom"');
const none = render(
createElement(SaveTargetFields, { ...props, instances: [], autoInstanceId: null, target: null }),
);
expect(none).toContain("没有可用的下载器");
});
});
describe("作品身份", () => {
const identityProps = {
formId: "f",
onToggle: () => {},
type: "movie" as const,
onTypeChange: () => {},
tmdbId: "",
onTmdbIdChange: () => {},
picked: null,
onPick: () => {},
onClearPick: () => {},
changed: false,
subject: "Spider-Man.2026.1080p",
};
it("未识别:默认展开,显示类型与 TMDB 编号输入和提示", () => {
const html = render(
createElement(IdentitySection, { ...identityProps, open: true, current: null, unrecognized: true }),
);
expect(html).toContain('aria-expanded="true"');
expect(html).toContain("未识别");
expect(html).toContain("TMDB 编号");
expect(html).toContain('id="f-tmdb"');
expect(html).toContain("暂未识别到作品");
});
it("已识别:折叠只显示当前作品", () => {
const html = render(
createElement(IdentitySection, {
...identityProps,
open: false,
current: { title: "蜘蛛侠:崭新之日", year: 2026, kind: "movie", tmdbId: "969681" },
unrecognized: false,
}),
);
expect(html).toContain('aria-expanded="false"');
expect(html).toContain("蜘蛛侠:崭新之日 (2026) · 电影 · TMDB 969681");
expect(html).not.toContain('id="f-tmdb"');
});
it("提交:未识别时指定 TMDB 身份就带上 identity,不再要求确认;本地作品给 mediaEntityId", () => {
const tmdb = buildManualOptions({
...base,
allowUnrecognized: true,
identity: { kind: "tmdb", type: "tv", id: "1399" },
});
expect(tmdb).toMatchObject({ identity: { external: { provider: "tmdb", entityType: "tv", externalId: "1399" } } });
expect(tmdb).not.toHaveProperty("allowUnrecognized");
expect(tmdb).not.toHaveProperty("entityId");
const local = buildManualOptions({ ...base, identity: { kind: "entity", id: E, title: "x" } });
expect(local.identity).toEqual({ mediaEntityId: E });
// 没指定或编号不完整:照旧确认后下载
expect(buildManualOptions({ ...base, allowUnrecognized: true })).toMatchObject({ allowUnrecognized: true });
expect(buildManualOptions({ ...base, identity: { kind: "tmdb", type: "movie", id: "12a" } })).not.toHaveProperty(
"identity",
);
});
it("已识别且身份没改:只带 entityId;改了身份就换成 identity 并退回正常入库", () => {
const same = buildManualOptions({
...base,
entityId: E,
currentTmdb: { type: "movie", id: "969681" },
identity: { kind: "tmdb", type: "movie", id: "969681" },
mode: "replace",
selected: [A],
});
expect(same).toMatchObject({ entityId: E, importMode: "replace", replacementAssetIds: [A] });
expect(same).not.toHaveProperty("identity");
const other = buildManualOptions({
...base,
entityId: E,
currentTmdb: { type: "movie", id: "969681" },
identity: { kind: "tmdb", type: "movie", id: "1" },
mode: "replace",
selected: [A],
});
expect(other).toMatchObject({ importMode: "normal", replacementAssetIds: [] });
expect(other).not.toHaveProperty("entityId");
});
it("类型缺省按标题解析出的季集猜", () => {
expect(kindHintOf({ season: 1 })).toBe("tv");
expect(kindHintOf({ season: null, episodes: { start: 1, end: 2 } })).toBe("tv");
expect(kindHintOf({ season: null, episodes: null })).toBe("movie");
expect(kindHintOf(undefined)).toBe("movie");
});
});
describe("已有版本", () => {
const version = (id: string, p: Partial<AssetVersion> = {}): AssetVersion =>
({
assetId: id,
status: "active",
resolution: "1080p",
source: "WEB-DL",
isEncode: true,
videoCodec: "H.264",
bitDepth: null,
hdr: [],
audio: [],
subtitleLanguages: [],
releaseGroup: null,
edition: null,
sizeBytes: 9_556_302_233,
durationSec: null,
fileName: `${id}.mkv`,
relativePath: null,
absolutePath: null,
libraryRootId: null,
locationStatus: null,
organizedAt: "2026-10-01T00:00:00.000Z",
downloadTaskId: null,
...p,
}) as AssetVersion;
it("摘要一行:规格 + 大小;多种规格只列前两种", () => {
expect(
versionSummaryText({
versionCount: 1,
sizeBytes: 9_556_302_233,
specs: [{ resolution: "1080p", source: "WEB-DL", hdr: [], count: 1 }],
}),
).toBe("媒体库已有:1080p WEB-DL · 8.9 GB");
expect(
versionSummaryText({
versionCount: 3,
sizeBytes: 0,
specs: [
{ resolution: "2160p", source: "WEB-DL", hdr: ["DV"], count: 1 },
{ resolution: "1080p", source: "BluRay", hdr: [], count: 1 },
{ resolution: "720p", source: "HDTV", hdr: [], count: 1 },
],
}),
).toBe("媒体库已有:2160p WEB-DL DV、1080p BluRay 等 3 种 · 3 个版本");
expect(versionSummaryText({ versionCount: 0, sizeBytes: 0, specs: [] })).toBeNull();
});
it("正常入库只显示摘要;选一次性洗版才展开勾选列表", () => {
const props = {
formId: "f",
loading: false,
error: false,
summary: "媒体库已有:1080p WEB-DL · 8.9 GB",
versions: [version(A), version(B)],
onModeChange: () => {},
selected: [A],
onSelectedChange: () => {},
};
const normal = render(createElement(ExistingVersionsSection, { ...props, mode: "normal" }));
expect(normal).toContain("媒体库已有:1080p WEB-DL · 8.9 GB");
expect(normal).not.toContain('type="checkbox"');
const replace = render(createElement(ExistingVersionsSection, { ...props, mode: "replace" }));
expect(replace).toContain("选择要替换的已有文件");
expect(replace.match(/type="checkbox"/g)).toHaveLength(2);
expect(replace).toContain(`${A}.mkv`);
expect(render(createElement(ExistingVersionsSection, { ...props, versions: [], mode: "normal" }))).toBe("");
});
});