diff --git a/apps/server/src/modules/acquisition-organize/routes/downloads.ts b/apps/server/src/modules/acquisition-organize/routes/downloads.ts index ab3269f3..5fb72e43 100644 --- a/apps/server/src/modules/acquisition-organize/routes/downloads.ts +++ b/apps/server/src/modules/acquisition-organize/routes/downloads.ts @@ -5,6 +5,7 @@ import type { ModuleApp } from "../../types.ts"; import { type AoDeps, enqueueTracked, timeSlot } from "../services/common.ts"; import { downloadBoard } from "../services/download-board.ts"; import { applySlotEvent, userPause, userResume } from "../services/downloads.ts"; +import { manualSavePaths } from "../services/manual-acquisition.ts"; import { planWhys } from "../services/plan-why.ts"; import { actorOf, conflict, notFound, toApi, toApiList } from "../util.ts"; import { mount, paginate } from "./helpers.ts"; @@ -38,6 +39,13 @@ export function registerDownloadRoutes(app: ModuleApp, d: AoDeps) { { tag: TAG }, ); + mount( + app, + Api.acquisitionPlans.savePaths, + async (_c, { query }) => manualSavePaths(db(), (query as { siteId?: string }).siteId), + { tag: TAG }, + ); + mount( app, Api.acquisitionPlans.why, diff --git a/apps/server/src/modules/acquisition-organize/services/manual-acquisition.ts b/apps/server/src/modules/acquisition-organize/services/manual-acquisition.ts index d58e0fcc..80252015 100644 --- a/apps/server/src/modules/acquisition-organize/services/manual-acquisition.ts +++ b/apps/server/src/modules/acquisition-organize/services/manual-acquisition.ts @@ -1,10 +1,34 @@ -import type { ManualAcquisitionRequest, ManualAcquisitionResult } from "@umt/contracts"; +import type { DownloadSavePaths, ManualAcquisitionRequest, ManualAcquisitionResult } from "@umt/contracts"; import { sql } from "@umt/db"; import { ApiException } from "../../../http/errors.ts"; import type { ServerContext } from "../../types.ts"; import { hashJson, jsonb, toApi } from "../util.ts"; import { subscriptionSavePath } from "./acquisition.ts"; -import { enqueueTracked, loadInstances, pickInstance } from "./common.ts"; +import { type Exec, enqueueTracked, hostPathOf, loadInstances, pickInstance } from "./common.ts"; + +/** + * 手动下载对话框的保存目录选项:每个可用下载器的默认保存目录(settings.savePath,没有时取第一条映射) + * 加上各条路径映射的下载器侧目录;「自动」按 pickInstance 同一顺序解析(站点指定 > 默认下载器 > 第一个)。 + */ +export async function manualSavePaths(exec: Exec, siteId?: string): Promise { + const insts = (await loadInstances(exec)).filter((t) => t.savePath); + const auto = await pickInstance(exec, insts, { siteId }); + return { + autoInstanceId: auto?.inst.id ?? null, + instances: insts.map((t) => { + const paths = [t.savePath!, ...t.mappings.map((m) => m.downloaderPath)]; + const seen = new Set(); + const dirs = []; + for (const p of paths) { + const key = p.replace(/\/+$/, "") || "/"; + if (seen.has(key)) continue; + seen.add(key); + dirs.push({ path: p, hostPath: hostPathOf(t, p), isDefault: dirs.length === 0 }); + } + return { instanceId: t.inst.id, name: t.inst.name, isDefault: t.inst.is_default, dirs }; + }), + }; +} /** Manual choice is a one-off plan, independent of subscription filters and slots. */ export async function planManualAcquisition( diff --git a/apps/server/src/modules/agent/routes.ts b/apps/server/src/modules/agent/routes.ts index 75acf024..e81ecdce 100644 --- a/apps/server/src/modules/agent/routes.ts +++ b/apps/server/src/modules/agent/routes.ts @@ -10,6 +10,7 @@ import { type AgentRunRow, sql } from "@umt/db"; import { hasScope } from "../../http/auth.ts"; import { ApiException } from "../../http/errors.ts"; import { planManualAcquisition } from "../acquisition-organize/services/manual-acquisition.ts"; +import { InternalDispatcher } from "../search/dispatch.ts"; import type { ModuleApp, ServerContext } from "../types.ts"; import { planBinding } from "./authorize.ts"; import { cancelTask, retryTask, taskToApi } from "./capabilities.ts"; @@ -115,6 +116,7 @@ export function registerAgentRoutes( limiter: InvokeLimiter = sharedInvokeLimiter(), ) { const db = () => ctx.db; + const dispatch = new InternalDispatcher(app); // ── Agent 会话 ── route(app, Api.agentRuns.list, async ({ query }) => { @@ -433,7 +435,19 @@ export function registerAgentRoutes( }); // 手动资源选择不依赖订阅,提交后仍走共同下载及整理任务。 - route(app, Api.acquisitionPlans.create, async ({ body, actorId, c }) => { + route(app, Api.acquisitionPlans.create, async ({ body: input, actorId, c }) => { + let body = input; + // 下载前指定了作品:走资源指定身份的同一端点(建 / 绑作品、人工锁、审计),再按该作品下载 + if (body.identity) { + const { identity, ...rest } = body; + const assigned = await dispatch.call<{ mediaEntityId: string }>( + c, + "POST", + `/source-releases/${body.sourceReleaseId}/identity`, + { ...identity, reason: "手动下载时指定" }, + ); + body = { ...rest, entityId: assigned.mediaEntityId }; + } const rel = await db() .selectFrom("source_release") .select("media_entity_id") diff --git a/apps/server/test/acquisition-organize/harness.ts b/apps/server/test/acquisition-organize/harness.ts index 2fd9a74d..f0e14947 100644 --- a/apps/server/test/acquisition-organize/harness.ts +++ b/apps/server/test/acquisition-organize/harness.ts @@ -14,6 +14,7 @@ import { createLogger } from "../../src/logger.ts"; import { type AoOverrides, createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts"; import type { AoDeps } from "../../src/modules/acquisition-organize/services/common.ts"; import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts"; +import { createAgentModule } from "../../src/modules/agent/index.ts"; import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts"; import type { ServerContext } from "../../src/modules/types.ts"; import { fakeEngine } from "../support/engine.ts"; @@ -90,8 +91,8 @@ export interface Harness { export async function createHarness( name: string, overrides: AoOverrides = {}, - /** withSources:同时装配 sources-identity 模块(待处理中心路由等) */ - opts: { withSources?: boolean } = {}, + /** withSources:同时装配 sources-identity 模块(待处理中心路由等);withAgent:再装配 agent 模块(POST /acquisition-plans 等) */ + opts: { withSources?: boolean; withAgent?: boolean } = {}, ): Promise { const ts = await createTestSchema(uniqueSchema(`ao_test_${name}`)); const { db } = ts; @@ -161,7 +162,11 @@ export async function createHarness( role: "all", version: "test", db, - modules: opts.withSources ? [module, createSourcesIdentityModule()] : [module], + modules: [ + module, + ...(opts.withSources || opts.withAgent ? [createSourcesIdentityModule()] : []), + ...(opts.withAgent ? [createAgentModule()] : []), + ], ctx, apiKeyStore: { async findByHash(hash) { diff --git a/apps/server/test/acquisition-organize/manual-download-options.test.ts b/apps/server/test/acquisition-organize/manual-download-options.test.ts new file mode 100644 index 00000000..2bf443fb --- /dev/null +++ b/apps/server/test/acquisition-organize/manual-download-options.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { createHarness, pgAvailable, seedBase, seedEntity, seedRelease } from "./harness.ts"; + +// 手动下载对话框:保存目录选项(默认目录 + 路径映射、「自动」按站点 / 默认下载器解析)与下载前指定作品。 + +const hasPg = await pgAvailable(); +describe.skipIf(!hasPg)("手动下载选项", () => { + it("保存目录选项:默认目录在前、映射目录去重、自动按站点指定的下载器", async () => { + const h = await createHarness("manual_save_paths"); + try { + const base = await seedBase(h.db, { libraryPath: "/lib", cachePath: "/downloads", qbUrl: "http://qb.invalid" }); + const other = await h.db + .insertInto("downloader_instance") + .values({ + name: "zz-mapped", + base_url: "http://qb2.invalid", + username: "admin", + is_default: true, + settings: JSON.stringify({ savePath: "/data/downloads" }), + }) + .returning("id") + .executeTakeFirstOrThrow(); + await h.db + .insertInto("path_mapping") + .values([ + { + downloader_instance_id: other.id, + view_kind: "downloader", + remote_prefix: "/data/downloads", + local_prefix: "/mnt/dl", + }, + { + downloader_instance_id: other.id, + view_kind: "downloader", + remote_prefix: "/data/tv", + local_prefix: "/mnt/tv", + }, + ]) + .execute(); + // 没有保存目录的下载器不列出 + await h.db + .insertInto("downloader_instance") + .values({ name: "no-path", base_url: "http://qb3.invalid", username: "admin" }) + .execute(); + + const all = await h.api("GET", "/download-save-paths"); + expect(all.status, JSON.stringify(all.body)).toBe(200); + expect(all.body.data.autoInstanceId).toBe(other.id); + expect(all.body.data.instances).toEqual([ + { + instanceId: base.instanceId, + name: expect.any(String), + isDefault: false, + dirs: [{ path: "/downloads", hostPath: "/downloads", isDefault: true }], + }, + { + instanceId: other.id, + name: "zz-mapped", + isDefault: true, + dirs: [ + { path: "/data/downloads", hostPath: "/mnt/dl", isDefault: true }, + { path: "/data/tv", hostPath: "/mnt/tv", isDefault: false }, + ], + }, + ]); + + await h.db + .updateTable("site") + .set({ downloader_instance_id: base.instanceId }) + .where("id", "=", base.siteId) + .execute(); + const bySite = await h.api("GET", `/download-save-paths?siteId=${base.siteId}`); + expect(bySite.body.data.autoInstanceId).toBe(base.instanceId); + } finally { + await h.close(); + } + }); + + it("下载前指定作品:按 TMDB 身份绑到已登记作品并加人工锁;本地作品同样绑定", async () => { + const h = await createHarness("manual_identity", {}, { withAgent: true }); + try { + const base = await seedBase(h.db, { libraryPath: "/lib", cachePath: "/downloads", qbUrl: "http://qb.invalid" }); + const entityId = await seedEntity(h.db, { kind: "movie", title: "Spider-Man", year: 2026 }); + await h.db + .insertInto("external_identity") + .values({ + media_entity_id: entityId, + provider: "tmdb", + entity_type: "movie", + external_id: "969681", + status: "accepted", + }) + .execute(); + const relId = await seedRelease(h.db, { + siteId: base.siteId, + entityId, + title: "Spider-Man.Brand.New.Day.2026.1080p.WEB-DL.mkv", + size: 300_000, + hash: "a".repeat(40), + }); + await h.db + .updateTable("source_release") + .set({ media_entity_id: null, resolution_status: "unresolved" }) + .where("id", "=", relId) + .execute(); + + const r = await h.api("POST", "/acquisition-plans", { + sourceReleaseId: relId, + identity: { external: { provider: "tmdb", entityType: "movie", externalId: "969681" } }, + }); + expect(r.status, JSON.stringify(r.body)).toBe(200); + expect(r.body.data).toMatchObject({ submitted: true, rejected: [] }); + expect(r.body.data.plan.evaluation.mediaEntityId).toBe(entityId); + const rel = await h.db + .selectFrom("source_release") + .select(["media_entity_id", "resolution_locked"]) + .where("id", "=", relId) + .executeTakeFirstOrThrow(); + expect(rel).toEqual({ media_entity_id: entityId, resolution_locked: true }); + + // 本地作品:同一端点绑定,旧的 entityId 被指定的作品取代 + const other = await seedEntity(h.db, { kind: "movie", title: "Other Movie", year: 2025 }); + const rel2 = await seedRelease(h.db, { + siteId: base.siteId, + entityId, + title: "Other.Movie.2025.2160p.mkv", + size: 300_000, + hash: "b".repeat(40), + }); + const r2 = await h.api("POST", "/acquisition-plans", { + sourceReleaseId: rel2, + entityId, + identity: { mediaEntityId: other }, + }); + expect(r2.status, JSON.stringify(r2.body)).toBe(200); + expect(r2.body.data.plan.evaluation.mediaEntityId).toBe(other); + expect( + ( + await h.db + .selectFrom("source_release") + .select("media_entity_id") + .where("id", "=", rel2) + .executeTakeFirstOrThrow() + ).media_entity_id, + ).toBe(other); + + // 本地作品与外部身份只能给一个,入参校验直接拒绝 + const bad = await h.api("POST", "/acquisition-plans", { + sourceReleaseId: rel2, + identity: { mediaEntityId: other, external: { provider: "tmdb", entityType: "movie", externalId: "1" } }, + }); + expect(bad.status).toBe(422); + } finally { + await h.close(); + } + }); +}); 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}`, diff --git a/packages/contracts/src/api/execution.ts b/packages/contracts/src/api/execution.ts index 43cf3cbc..9ef097b6 100644 --- a/packages/contracts/src/api/execution.ts +++ b/packages/contracts/src/api/execution.ts @@ -64,6 +64,14 @@ export const executionApi = { body: execution.ManualAcquisitionRequest, response: execution.ManualAcquisitionResult, }), + savePaths: ep({ + method: "get", + path: "/download-save-paths", + scope: "read", + summary: "手动下载可选的下载器与保存目录(默认保存目录 + 路径映射),以及「自动」会用哪个下载器", + query: execution.DownloadSavePathQuery, + response: execution.DownloadSavePaths, + }), why: ep({ method: "get", path: "/acquisition-plans/{id}/why", diff --git a/packages/contracts/src/execution.ts b/packages/contracts/src/execution.ts index f3d67091..95baaaa4 100644 --- a/packages/contracts/src/execution.ts +++ b/packages/contracts/src/execution.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common.ts"; +import { ExternalRef } from "./identity.ts"; import { AcquisitionPriority } from "./policy.ts"; // 执行域:业务任务、下载计划、下载任务、操作账本(REQ §8.4、§11;ADR-002) @@ -146,6 +147,13 @@ export const ManualAcquisitionRequest = z.object({ allowUnrecognized: z.boolean().optional(), importMode: z.enum(["normal", "replace"]).default("normal"), replacementAssetIds: z.array(Id).default([]), + /** + * 下载前指定作品:本地作品或外部身份(未登记的按 TMDB 详情加入媒体库), + * 与 POST /source-releases/{id}/identity 同一实现,绑定到资源并加人工锁;给了就不再用 entityId。 + */ + identity: z + .union([z.object({ mediaEntityId: Id }).strict(), z.object({ external: ExternalRef }).strict()]) + .optional(), }); export type ManualAcquisitionRequest = z.infer; @@ -161,6 +169,36 @@ export const ManualAcquisitionResult = z.object({ rejected: z.array(z.object({ slotId: Id.nullable(), code: z.string(), message: z.string() })), }); export type ManualAcquisitionResult = z.infer; +/** 手动下载可选的保存目录:按下载器列出默认保存目录与路径映射的下载器侧目录 */ +export const DownloadSavePathQuery = z.object({ + /** 资源所在站点;站点指定了下载器时「自动」按它解析 */ + siteId: Id.optional(), +}); +export type DownloadSavePathQuery = z.infer; +export const DownloadSaveDir = z.object({ + /** 下载器视角的路径(提交给下载器的保存路径) */ + path: z.string(), + /** 本机视角的路径;配置了映射但对不上时为 null */ + hostPath: z.string().nullable(), + /** 下载器设置里的默认保存目录(「自动」用它) */ + isDefault: z.boolean(), +}); +export type DownloadSaveDir = z.infer; +export const DownloadSaveTarget = z.object({ + instanceId: Id, + name: z.string(), + isDefault: z.boolean(), + /** 第一个是默认保存目录 */ + dirs: z.array(DownloadSaveDir), +}); +export type DownloadSaveTarget = z.infer; +export const DownloadSavePaths = z.object({ + /** 不指定下载器时会用的那个(站点指定 > 默认下载器 > 第一个有保存目录的);没有可用下载器为 null */ + autoInstanceId: Id.nullable(), + instances: z.array(DownloadSaveTarget), +}); +export type DownloadSavePaths = z.infer; + export const AcquisitionPlanUpdate = AcquisitionPlan.pick({ status: true, taskId: true, statusReason: true }).partial(); export type AcquisitionPlanUpdate = z.infer;