From 6e645bce8f112c3fc2a80a9ad298ef453fb08f9c Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 12:10:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E4=B8=8B=E8=BD=BD=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=A1=86):=20=E5=AD=A6=20MoviePilot=20=E9=87=8D=E5=81=9A?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E4=B8=8B=E8=BD=BD=E5=AF=B9=E8=AF=9D=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 标题区:海报、作品名 (年份)、站点;资源行显示原始标题、促销、H&R、做种 / 下载、大小、发布时间 - 下载器与保存目录并排下拉,缺省「自动」,下方写明「将保存到」的实际路径;保留「自定义路径」 - 作品身份折叠区:类型 + TMDB 编号(可搜索作品),未识别时默认展开,指定后随下载绑定 - 已有版本先显示一行摘要,选一次性洗版才展开勾选列表 - 搜索、工作台、原站视图三处调用方补传资源字段 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- apps/web/app/api/downloads.ts | 13 + .../components/browser/resource-download.tsx | 7 +- .../downloads/manual-download-dialog.test.ts | 274 ++++++++ .../downloads/manual-download-dialog.tsx | 651 +++++++++++++++--- .../downloads/manual-download-model.ts | 128 ++++ .../search/search-download-dialog.tsx | 31 +- .../components/workbench/download-dialog.tsx | 25 +- 7 files changed, 1012 insertions(+), 117 deletions(-) create mode 100644 apps/web/app/components/downloads/manual-download-dialog.test.ts create mode 100644 apps/web/app/components/downloads/manual-download-model.ts diff --git a/apps/web/app/api/downloads.ts b/apps/web/app/api/downloads.ts index 143e7200..7897b7b6 100644 --- a/apps/web/app/api/downloads.ts +++ b/apps/web/app/api/downloads.ts @@ -6,6 +6,7 @@ import { DownloadBoard, type DownloadBoardState, DownloaderInstance, + DownloadSavePaths, type DownloadStatus, DownloadTask, DownloadWhy, @@ -31,8 +32,20 @@ export const downloadKeys = { why: (id: string) => ["downloads", "why", id] as const, cache: () => ["downloads", "cache"] as const, board: (state: DownloadBoardState) => ["downloads", "board", state] as const, + savePaths: (siteId?: string) => ["downloads", "save-paths", siteId ?? null] as const, }; +/** 手动下载可选的下载器与保存目录;siteId 决定「自动」用哪个下载器 */ +export function useDownloadSavePaths(siteId: string | undefined, enabled = true) { + return useQuery({ + queryKey: downloadKeys.savePaths(siteId), + enabled, + queryFn: ({ signal }) => + apiParsed(DownloadSavePaths, "/api/v1/download-save-paths", { params: cleanParams({ siteId }), signal }), + staleTime: 30_000, + }); +} + const ACTIVE: DownloadStatus[] = ["queued", "submitting", "downloading", "importing", "stalled"]; export function useDownloadTasks(f: DownloadFilters) { diff --git a/apps/web/app/components/browser/resource-download.tsx b/apps/web/app/components/browser/resource-download.tsx index 05fca16c..9f9fccd6 100644 --- a/apps/web/app/components/browser/resource-download.tsx +++ b/apps/web/app/components/browser/resource-download.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { useSubmitRelease } from "~/api/releases.ts"; import { ManualDownloadDialog } from "~/components/downloads/manual-download-dialog.tsx"; import { Button } from "~/components/ui/button.tsx"; +import { workbenchResource } from "~/components/workbench/download-dialog.tsx"; import { apiData, errorMessage } from "~/lib/api.ts"; /** 控件位于 iframe/截图外;直接解析当前详情页并交给共同手动下载入口。 */ @@ -72,11 +73,7 @@ export function ResourceDownload({ session }: { session: BrowserSessionView }) { {resource ? ( + 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("自定义路径"); + 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 => + ({ + 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(""); + }); +}); diff --git a/apps/web/app/components/downloads/manual-download-dialog.tsx b/apps/web/app/components/downloads/manual-download-dialog.tsx index 9b943a23..7c96ec19 100644 --- a/apps/web/app/components/downloads/manual-download-dialog.tsx +++ b/apps/web/app/components/downloads/manual-download-dialog.tsx @@ -1,11 +1,19 @@ -import type { AssetVersion, ManualAcquisitionRequest, ManualAcquisitionResult } from "@umt/contracts"; -import { Bot, Download } from "lucide-react"; +import type { AssetVersion, DownloadSaveTarget, ManualAcquisitionResult } from "@umt/contracts"; +import { ArrowDown, ArrowUp, Bot, ChevronDown, Download, Search, X } from "lucide-react"; import { useEffect, useId, useState } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; -import { downloaders } from "~/api/instances.ts"; +import { useDownloadSavePaths } from "~/api/downloads.ts"; +import { useEntityDetail } from "~/api/library.ts"; import { useLibraryVersions } from "~/api/library-versions.ts"; +import { useTmdbMedia } from "~/api/search.ts"; +import { useSite } from "~/api/sites.ts"; import { FilterSelect } from "~/components/console/filter-bar.tsx"; +import { FormField } from "~/components/console/form-field.tsx"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { MEDIA_KIND } from "~/components/library/labels.ts"; +import { CoverPlaceholder, posterPathOf } from "~/components/library/parts.tsx"; +import { type AssignedWork, WorkAssignDialog } from "~/components/library/work-assign-dialog.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Dialog, @@ -16,16 +24,49 @@ import { DialogTitle, } from "~/components/ui/dialog.tsx"; import { Input } from "~/components/ui/input.tsx"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; +import { absTime, relTime } from "~/components/workbench/release-fields.ts"; import { errorMessage } from "~/lib/api.ts"; -import { formatBytes } from "~/lib/utils.ts"; +import { cn, formatBytes } from "~/lib/utils.ts"; +import { + buildManualOptions, + CUSTOM_DIR, + existingVersions, + type IdentityChoice, + identityChanged, + type ManualDownloadOptions, + resolveSavePath, + savePathText, + type TmdbType, + targetOf, + tmdbTypeOf, + versionSummaryText, +} from "./manual-download-model.ts"; -export type ManualDownloadOptions = Omit; +export type { ManualDownloadOptions } from "./manual-download-model.ts"; + +/** 调用方传入的资源摘要;缺的字段不显示 */ export interface ManualDownloadResource { title: string; + subtitle?: string | null; sizeBytes: number | null; - entityId?: string; + /** 已识别到的作品 */ + entityId?: string | undefined; + entity?: { title: string; year: number | null; kind: string } | null; + siteId?: string; + /** 没给时按 siteId 查站点名 */ + siteName?: string | null; + seeders?: number | null; + leechers?: number | null; + promotion?: { label: string; free: boolean } | null; + hr?: "yes" | "no" | "unknown"; + publishedAt?: string | null; + /** 未识别时身份区的类型缺省值(按标题解析出的季集猜) */ + kindHint?: TmdbType; } +const AUTO = "__auto__"; + /** 搜索、工作台和原站视图共用的一次性下载选项。 */ export function ManualDownloadDialog({ resource, @@ -42,23 +83,48 @@ export function ManualDownloadDialog({ initialUnrecognized?: boolean; onDelegate?: () => void; }) { - const instances = downloaders.useList(); const formId = useId(); - const inventory = useLibraryVersions(open ? resource.entityId : undefined); + const entityId = resource.entityId; + const paths = useDownloadSavePaths(resource.siteId, open); + const detail = useEntityDetail(open ? entityId : undefined); + const inventory = useLibraryVersions(open ? entityId : undefined); + const site = useSite(open && !resource.siteName ? resource.siteId : undefined); const [instanceId, setInstanceId] = useState(""); - const [savePath, setSavePath] = useState(""); + const [dir, setDir] = useState(""); + const [custom, setCustom] = useState(""); const [mode, setMode] = useState<"normal" | "replace">("normal"); const [selected, setSelected] = useState([]); const [unrecognized, setUnrecognized] = useState(initialUnrecognized); + const [identityOpen, setIdentityOpen] = useState(!entityId); + // null = 没动过,显示作品当前的值 + const [tmdbType, setTmdbType] = useState(null); + const [tmdbId, setTmdbId] = useState(null); + const [picked, setPicked] = useState<{ id: string; title: string } | null>(null); const [busy, setBusy] = useState(false); const [result, setResult] = useState(null); - const versions = new Map(); - for (const v of inventory.data?.movie?.versions ?? []) versions.set(v.assetId, v); - for (const season of inventory.data?.seasons ?? []) - for (const episode of season.episodes) for (const v of episode.versions) versions.set(v.assetId, v); - for (const v of inventory.data?.unplaced ?? []) versions.set(v.assetId, v); - const existing = [...versions.values()]; + + const entity = detail.data?.entity; + const tmdbIdentity = detail.data?.identities.find( + (i) => i.provider === "tmdb" && i.status === "accepted" && !i.contentUnitId, + ); + const currentTmdb = tmdbIdentity + ? { type: (tmdbIdentity.entityType === "movie" ? "movie" : "tv") as TmdbType, id: tmdbIdentity.externalId } + : null; + const shownType = + tmdbType ?? currentTmdb?.type ?? (entityId ? tmdbTypeOf(resource.entity?.kind) : (resource.kindHint ?? "movie")); + const shownId = tmdbId ?? currentTmdb?.id ?? ""; + const choice: IdentityChoice | null = picked + ? { kind: "entity", id: picked.id, title: picked.title } + : shownId + ? { kind: "tmdb", type: shownType, id: shownId } + : null; + const changed = identityChanged(choice, { entityId, tmdb: currentTmdb }); + + const target = targetOf(paths.data, instanceId); + const existing = changed ? [] : existingVersions(inventory.data); const single = existing.length === 1 ? existing[0]!.assetId : null; + const effectiveMode = changed ? "normal" : mode; + useEffect(() => { if (mode === "replace" && single) setSelected([single]); }, [mode, single]); @@ -66,21 +132,32 @@ export function ManualDownloadDialog({ if (open) { setResult(null); setUnrecognized(initialUnrecognized); + if (initialUnrecognized) setIdentityOpen(true); } }, [open, initialUnrecognized]); + const go = async () => { setBusy(true); try { - const r = await submit({ - ...(resource.entityId ? { entityId: resource.entityId } : {}), - ...(instanceId ? { downloaderInstanceId: instanceId } : {}), - ...(savePath.trim() ? { savePath: savePath.trim() } : {}), - ...(unrecognized ? { allowUnrecognized: true } : {}), - importMode: mode, - replacementAssetIds: mode === "replace" ? selected : [], - }); + const r = await submit( + buildManualOptions({ + entityId, + currentTmdb, + identity: choice, + instanceId, + target, + dir, + custom, + allowUnrecognized: unrecognized, + mode: effectiveMode, + selected, + }), + ); setResult(r); - if (r.requiresConfirmation) setUnrecognized(true); + if (r.requiresConfirmation) { + setUnrecognized(true); + setIdentityOpen(true); + } if (r.submitted) toast.success("已提交下载"); } catch (e) { toast.error(errorMessage(e)); @@ -88,89 +165,102 @@ export function ManualDownloadDialog({ setBusy(false); } }; + + const work = resource.entity ?? (entity ? { title: entity.title, year: entity.year, kind: entity.kind } : null); + const siteName = resource.siteName ?? site.data?.name ?? null; + const missingCustom = dir === CUSTOM_DIR && !custom.trim(); + const badTmdb = !picked && !!shownId && !/^\d+$/.test(shownId); + return ( - + - 下载这个资源 - - {resource.title} - {resource.sizeBytes !== null ? ` · ${formatBytes(resource.sizeBytes, 1)}` : ""} - +
+ {work ? ( + + ) : ( + + + + )} +
+ + {work ? `${work.title}${work.year ? ` (${work.year})` : ""}` : "下载这个资源"} + + + {[siteName, work ? (MEDIA_KIND[work.kind as keyof typeof MEDIA_KIND] ?? null) : "未识别作品"] + .filter(Boolean) + .join(" · ")} + +
+
-
- v.enabled) - .map((v) => ({ value: v.id, label: v.name }))} +
+ + { + setInstanceId(v); + if (dir !== CUSTOM_DIR) setDir(""); + }} + target={target} + dir={dir} + onDirChange={setDir} + custom={custom} + onCustomChange={setCustom} /> - - setMode(v === "replace" ? "replace" : "normal")} - allLabel="正常入库,保留已有版本" - options={[{ value: "replace", label: "一次性洗版入库" }]} - /> - {resource.entityId && inventory.isPending ? ( -

正在读取已有版本…

- ) : null} - {inventory.error ? ( -

已有版本读取失败,请重试后选择洗版。

- ) : null} - {existing.length ? ( -
- - {mode === "replace" ? "选择要替换的已有文件" : "媒体库已有版本"} - - {existing.map((v) => ( -
- {mode === "replace" ? ( - - setSelected((ids) => - e.target.checked ? [...ids, v.assetId] : ids.filter((id) => id !== v.assetId), - ) - } - /> - ) : null} - {mode === "replace" ? ( - - ) : ( - - )} -
- ))} -
- ) : mode === "replace" ? ( -

没有可替换的已有文件,请选择正常入库。

- ) : null} - {mode === "replace" ? ( -

- 本次新文件成功入库后替换所选旧文件。剧集只替换成功对应的季集,其余保留。 -

- ) : null} - {unrecognized ? ( -

- 暂未识别到作品。可以继续下载;整理时会尝试补全识别和本地元数据。 -

) : null} {result && !result.submitted && !result.requiresConfirmation ? (

{result.rejected.map((r) => r.message).join(";")}

@@ -200,11 +290,13 @@ export function ManualDownloadDialog({ disabled={ busy || !!result?.submitted || - (mode === "replace" && (!selected.length || inventory.isPending || !!inventory.error)) + missingCustom || + badTmdb || + (effectiveMode === "replace" && (!selected.length || inventory.isPending || !!inventory.error)) } > - {busy ? "提交中…" : unrecognized ? "继续下载" : "提交下载"} + {busy ? "提交中…" : unrecognized && !changed ? "继续下载" : "开始下载"} @@ -212,12 +304,363 @@ export function ManualDownloadDialog({ ); } +/** 资源行:原始标题、促销、H&R、做种 / 下载、大小、发布时间 */ +export function ResourceSummary({ resource: r }: { resource: ManualDownloadResource }) { + return ( +
+
+

{r.title}

+ + {r.promotion ? ( + {r.promotion.label} + ) : null} + {r.hr === "yes" ? H&R : null} + {r.hr === "unknown" ? H&R 未知 : null} + +
+ {r.subtitle ?

{r.subtitle}

: null} +
+ {r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)} + {r.seeders !== undefined || r.leechers !== undefined ? ( + + + {r.seeders ?? "—"} + + {r.leechers ?? "—"} + + ) : null} + {r.publishedAt ? 发布 {relTime(r.publishedAt)} : null} +
+
+ ); +} + +/** 下载器 + 保存目录并排;下方写明实际会保存到哪 */ +export function SaveTargetFields({ + formId, + instances, + autoInstanceId, + loading = false, + error = null, + instanceId, + onInstanceChange, + target, + dir, + onDirChange, + custom, + onCustomChange, +}: { + formId: string; + instances: DownloadSaveTarget[]; + autoInstanceId: string | null; + loading?: boolean; + error?: string | null; + instanceId: string; + onInstanceChange: (v: string) => void; + target: DownloadSaveTarget | null; + dir: string; + onDirChange: (v: string) => void; + custom: string; + onCustomChange: (v: string) => void; +}) { + const auto = instances.find((t) => t.instanceId === autoInstanceId); + const resolved = resolveSavePath(target, dir, custom); + const pathHint = error + ? error + : loading + ? "加载中…" + : !target && dir !== CUSTOM_DIR + ? "没有可用的下载器,先在设置里添加下载器和保存目录" + : resolved + ? `将保存到:${savePathText(resolved)}` + : "填写下载器能访问的路径"; + return ( +
+ + + + + + + {dir === CUSTOM_DIR ? ( + + onCustomChange(e.target.value)} + placeholder="/downloads/movies" + /> + + ) : null} +
+ ); +} + +/** 作品身份:类型 + TMDB 编号(可搜索);已识别时折叠,未识别时默认展开 */ +export function IdentitySection({ + formId, + open, + onToggle, + current, + unrecognized, + type, + onTypeChange, + tmdbId, + onTmdbIdChange, + picked, + onPick, + onClearPick, + changed, + subject, +}: { + formId: string; + open: boolean; + onToggle: () => void; + current: { title: string; year: number | null; kind: string; tmdbId: string | null } | null; + unrecognized: boolean; + type: TmdbType; + onTypeChange: (v: TmdbType) => void; + tmdbId: string; + onTmdbIdChange: (v: string) => void; + picked: { id: string; title: string } | null; + onPick: (w: AssignedWork) => void; + onClearPick: () => void; + changed: boolean; + subject: string; +}) { + const [searching, setSearching] = useState(false); + const valid = /^\d+$/.test(tmdbId); + const preview = useTmdbMedia(open && !picked && valid && changed ? type : undefined, tmdbId); + const summary = current + ? [ + `${current.title}${current.year ? ` (${current.year})` : ""}`, + MEDIA_KIND[current.kind as keyof typeof MEDIA_KIND], + current.tmdbId ? `TMDB ${current.tmdbId}` : null, + ] + .filter(Boolean) + .join(" · ") + : "未识别"; + return ( +
+ + {open ? ( +
+ {unrecognized && !changed ? ( +

+ 暂未识别到作品。指定后绑定到这个资源;不指定也能下载,整理时再补全识别和元数据。 +

+ ) : null} + {picked ? ( +
+ + {picked.title} + · 在媒体库 + + +
+ ) : ( +
+ + + + +
+ onTmdbIdChange(e.target.value)} + placeholder="例如 969681" + aria-invalid={!!tmdbId && !valid} + /> + +
+
+
+ )} +
+ ) : null} + onPick(w)} + /> +
+ ); +} + +/** 已有版本:先一行摘要;选一次性洗版时展开勾选要替换的文件 */ +export function ExistingVersionsSection({ + formId, + loading, + error, + summary, + versions, + mode, + onModeChange, + selected, + onSelectedChange, +}: { + formId: string; + loading: boolean; + error: boolean; + summary: string | null; + versions: AssetVersion[]; + mode: "normal" | "replace"; + onModeChange: (m: "normal" | "replace") => void; + selected: string[]; + onSelectedChange: (ids: string[]) => void; +}) { + if (loading) return

正在读取已有版本…

; + if (error) return

已有版本读取失败,重试后再选洗版

; + if (!versions.length) return null; + return ( +
+
+

{summary ?? `媒体库已有 ${versions.length} 个版本`}

+ onModeChange(v === "replace" ? "replace" : "normal")} + allLabel="正常入库,保留已有版本" + options={[{ value: "replace", label: "一次性洗版" }]} + /> +
+ {mode === "replace" ? ( + <> +
+ 选择要替换的已有文件 + {versions.map((v) => ( +
+ + onSelectedChange( + e.target.checked ? [...selected, v.assetId] : selected.filter((id) => id !== v.assetId), + ) + } + /> + +
+ ))} +
+

+ 本次新文件成功入库后替换所选旧文件。剧集只替换成功对应的季集,其余保留。 +

+ + ) : null} +
+ ); +} + function VersionDescription({ version }: { version: AssetVersion }) { return ( {version.fileName} - {[version.resolution, version.source, version.videoCodec, ...version.hdr, version.edition] + {[ + version.resolution, + version.source, + version.videoCodec, + ...version.hdr, + version.edition, + version.sizeBytes ? formatBytes(version.sizeBytes, 1) : null, + ] .filter(Boolean) .join(" · ")} diff --git a/apps/web/app/components/downloads/manual-download-model.ts b/apps/web/app/components/downloads/manual-download-model.ts new file mode 100644 index 00000000..1da98f10 --- /dev/null +++ b/apps/web/app/components/downloads/manual-download-model.ts @@ -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; +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(); + 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"); diff --git a/apps/web/app/components/search/search-download-dialog.tsx b/apps/web/app/components/search/search-download-dialog.tsx index 89d062a7..9e1c43cb 100644 --- a/apps/web/app/components/search/search-download-dialog.tsx +++ b/apps/web/app/components/search/search-download-dialog.tsx @@ -1,7 +1,9 @@ import type { SearchRelease } from "@umt/contracts"; import { usePlanFromSearch } from "~/api/search.ts"; import { openAgentDrawer } from "~/components/agent/agent-store.ts"; -import { ManualDownloadDialog } from "~/components/downloads/manual-download-dialog.tsx"; +import { ManualDownloadDialog, type ManualDownloadResource } from "~/components/downloads/manual-download-dialog.tsx"; +import { kindHintOf } from "~/components/downloads/manual-download-model.ts"; +import { promoLabel } from "./search-labels.ts"; export function delegateToAgent(release: SearchRelease) { openAgentDrawer({ @@ -13,6 +15,27 @@ export function delegateToAgent(release: SearchRelease) { }); } +/** 搜索结果 → 下载对话框的资源摘要;别名命中(未确认)按未识别处理 */ +export function searchResource(release: SearchRelease): ManualDownloadResource { + const resolved = release.entityMatch === "resolved" ? release.entity : null; + const promo = promoLabel(release.promotion.type); + return { + title: release.title, + subtitle: release.subtitle, + sizeBytes: release.sizeBytes, + entityId: resolved?.id, + entity: resolved, + siteId: release.siteId, + siteName: release.siteName, + seeders: release.seeders, + leechers: release.leechers, + promotion: promo ? { label: promo, free: release.free } : null, + hr: release.hr === "unknown" ? "unknown" : release.hr ? "yes" : "no", + publishedAt: release.uploadedAt, + kindHint: kindHintOf(release.parsed), + }; +} + export function SearchDownloadDialog({ release, open, @@ -28,11 +51,7 @@ export function SearchDownloadDialog({ key={`${release.siteId}:${release.siteReleaseId}`} open={open} onOpenChange={onOpenChange} - resource={{ - title: release.title, - sizeBytes: release.sizeBytes, - entityId: release.entityMatch === "resolved" ? release.entity?.id : undefined, - }} + resource={searchResource(release)} onDelegate={() => delegateToAgent(release)} submit={(options) => submit.mutateAsync({ ...options, siteId: release.siteId, siteReleaseId: release.siteReleaseId }) diff --git a/apps/web/app/components/workbench/download-dialog.tsx b/apps/web/app/components/workbench/download-dialog.tsx index f2b0c70c..5cb9b4f3 100644 --- a/apps/web/app/components/workbench/download-dialog.tsx +++ b/apps/web/app/components/workbench/download-dialog.tsx @@ -1,7 +1,28 @@ import type { AcquisitionSlot, WorkbenchRelease } from "@umt/contracts"; import { useSubmitRelease } from "~/api/releases.ts"; import { openAgentDrawer } from "~/components/agent/agent-store.ts"; -import { ManualDownloadDialog } from "~/components/downloads/manual-download-dialog.tsx"; +import { ManualDownloadDialog, type ManualDownloadResource } from "~/components/downloads/manual-download-dialog.tsx"; +import { kindHintOf } from "~/components/downloads/manual-download-model.ts"; +import { hrOf, promotionLabel } from "./release-fields.ts"; + +/** 站点资源 → 下载对话框的资源摘要(工作台与原站视图共用);过期的促销不显示 */ +export function workbenchResource(r: WorkbenchRelease): ManualDownloadResource { + const promo = promotionLabel(r); + return { + title: r.title, + subtitle: r.subtitle, + sizeBytes: r.sizeBytes, + entityId: r.entity?.id, + entity: r.entity, + siteId: r.siteId, + seeders: r.seeders, + leechers: r.leechers, + promotion: promo && !promo.expired ? { label: promo.label, free: promo.free } : null, + hr: hrOf(r), + publishedAt: r.sourcePublishedAt, + kindHint: kindHintOf(r.parsed), + }; +} /** 工作台使用独立手动下载,不要求先建立订阅或收录版本。 */ export function DownloadDialog({ @@ -20,7 +41,7 @@ export function DownloadDialog({ key={release.id} open={open} onOpenChange={onOpenChange} - resource={{ title: release.title, sizeBytes: release.sizeBytes, entityId: release.entity?.id }} + resource={workbenchResource(release)} onDelegate={() => openAgentDrawer({ label: `资源:${release.title}`,