merge: ui/j-download-dialog

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:22:37 +08:00
commit 1360120e75
14 files changed
+1272 -123

No files matched your search

@@ -5,6 +5,7 @@ import type { ModuleApp } from "../../types.ts";
import { type AoDeps, enqueueTracked, timeSlot } from "../services/common.ts"; import { type AoDeps, enqueueTracked, timeSlot } from "../services/common.ts";
import { downloadBoard } from "../services/download-board.ts"; import { downloadBoard } from "../services/download-board.ts";
import { applySlotEvent, userPause, userResume } from "../services/downloads.ts"; import { applySlotEvent, userPause, userResume } from "../services/downloads.ts";
import { manualSavePaths } from "../services/manual-acquisition.ts";
import { planWhys } from "../services/plan-why.ts"; import { planWhys } from "../services/plan-why.ts";
import { actorOf, conflict, notFound, toApi, toApiList } from "../util.ts"; import { actorOf, conflict, notFound, toApi, toApiList } from "../util.ts";
import { mount, paginate } from "./helpers.ts"; import { mount, paginate } from "./helpers.ts";
@@ -38,6 +39,13 @@ export function registerDownloadRoutes(app: ModuleApp, d: AoDeps) {
{ tag: TAG }, { tag: TAG },
); );
mount(
app,
Api.acquisitionPlans.savePaths,
async (_c, { query }) => manualSavePaths(db(), (query as { siteId?: string }).siteId),
{ tag: TAG },
);
mount( mount(
app, app,
Api.acquisitionPlans.why, Api.acquisitionPlans.why,
@@ -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 { sql } from "@umt/db";
import { ApiException } from "../../../http/errors.ts"; import { ApiException } from "../../../http/errors.ts";
import type { ServerContext } from "../../types.ts"; import type { ServerContext } from "../../types.ts";
import { hashJson, jsonb, toApi } from "../util.ts"; import { hashJson, jsonb, toApi } from "../util.ts";
import { subscriptionSavePath } from "./acquisition.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<DownloadSavePaths> {
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<string>();
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. */ /** Manual choice is a one-off plan, independent of subscription filters and slots. */
export async function planManualAcquisition( export async function planManualAcquisition(
+15 -1
View File
@@ -10,6 +10,7 @@ import { type AgentRunRow, sql } from "@umt/db";
import { hasScope } from "../../http/auth.ts"; import { hasScope } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts"; import { ApiException } from "../../http/errors.ts";
import { planManualAcquisition } from "../acquisition-organize/services/manual-acquisition.ts"; import { planManualAcquisition } from "../acquisition-organize/services/manual-acquisition.ts";
import { InternalDispatcher } from "../search/dispatch.ts";
import type { ModuleApp, ServerContext } from "../types.ts"; import type { ModuleApp, ServerContext } from "../types.ts";
import { planBinding } from "./authorize.ts"; import { planBinding } from "./authorize.ts";
import { cancelTask, retryTask, taskToApi } from "./capabilities.ts"; import { cancelTask, retryTask, taskToApi } from "./capabilities.ts";
@@ -115,6 +116,7 @@ export function registerAgentRoutes(
limiter: InvokeLimiter = sharedInvokeLimiter(), limiter: InvokeLimiter = sharedInvokeLimiter(),
) { ) {
const db = () => ctx.db; const db = () => ctx.db;
const dispatch = new InternalDispatcher(app);
// ── Agent 会话 ── // ── Agent 会话 ──
route(app, Api.agentRuns.list, async ({ query }) => { 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() const rel = await db()
.selectFrom("source_release") .selectFrom("source_release")
.select("media_entity_id") .select("media_entity_id")
@@ -14,6 +14,7 @@ import { createLogger } from "../../src/logger.ts";
import { type AoOverrides, createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts"; import { type AoOverrides, createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts";
import type { AoDeps } from "../../src/modules/acquisition-organize/services/common.ts"; import type { AoDeps } from "../../src/modules/acquisition-organize/services/common.ts";
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.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 { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts";
import type { ServerContext } from "../../src/modules/types.ts"; import type { ServerContext } from "../../src/modules/types.ts";
import { fakeEngine } from "../support/engine.ts"; import { fakeEngine } from "../support/engine.ts";
@@ -90,8 +91,8 @@ export interface Harness {
export async function createHarness( export async function createHarness(
name: string, name: string,
overrides: AoOverrides = {}, overrides: AoOverrides = {},
/** withSources:同时装配 sources-identity 模块(待处理中心路由等) */ /** withSources:同时装配 sources-identity 模块(待处理中心路由等);withAgent:再装配 agent 模块(POST /acquisition-plans 等) */
opts: { withSources?: boolean } = {}, opts: { withSources?: boolean; withAgent?: boolean } = {},
): Promise<Harness> { ): Promise<Harness> {
const ts = await createTestSchema(uniqueSchema(`ao_test_${name}`)); const ts = await createTestSchema(uniqueSchema(`ao_test_${name}`));
const { db } = ts; const { db } = ts;
@@ -161,7 +162,11 @@ export async function createHarness(
role: "all", role: "all",
version: "test", version: "test",
db, db,
modules: opts.withSources ? [module, createSourcesIdentityModule()] : [module], modules: [
module,
...(opts.withSources || opts.withAgent ? [createSourcesIdentityModule()] : []),
...(opts.withAgent ? [createAgentModule()] : []),
],
ctx, ctx,
apiKeyStore: { apiKeyStore: {
async findByHash(hash) { async findByHash(hash) {
@@ -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();
}
});
});
+13
View File
@@ -6,6 +6,7 @@ import {
DownloadBoard, DownloadBoard,
type DownloadBoardState, type DownloadBoardState,
DownloaderInstance, DownloaderInstance,
DownloadSavePaths,
type DownloadStatus, type DownloadStatus,
DownloadTask, DownloadTask,
DownloadWhy, DownloadWhy,
@@ -31,8 +32,20 @@ export const downloadKeys = {
why: (id: string) => ["downloads", "why", id] as const, why: (id: string) => ["downloads", "why", id] as const,
cache: () => ["downloads", "cache"] as const, cache: () => ["downloads", "cache"] as const,
board: (state: DownloadBoardState) => ["downloads", "board", state] 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"]; const ACTIVE: DownloadStatus[] = ["queued", "submitting", "downloading", "importing", "stalled"];
export function useDownloadTasks(f: DownloadFilters) { export function useDownloadTasks(f: DownloadFilters) {
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { useSubmitRelease } from "~/api/releases.ts"; import { useSubmitRelease } from "~/api/releases.ts";
import { ManualDownloadDialog } from "~/components/downloads/manual-download-dialog.tsx"; import { ManualDownloadDialog } from "~/components/downloads/manual-download-dialog.tsx";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
import { workbenchResource } from "~/components/workbench/download-dialog.tsx";
import { apiData, errorMessage } from "~/lib/api.ts"; import { apiData, errorMessage } from "~/lib/api.ts";
/** 控件位于 iframe/截图外;直接解析当前详情页并交给共同手动下载入口。 */ /** 控件位于 iframe/截图外;直接解析当前详情页并交给共同手动下载入口。 */
@@ -72,11 +73,7 @@ export function ResourceDownload({ session }: { session: BrowserSessionView }) {
{resource ? ( {resource ? (
<ManualDownloadDialog <ManualDownloadDialog
key={resource.release.id} key={resource.release.id}
resource={{ resource={workbenchResource(resource.release)}
title: resource.release.title,
sizeBytes: resource.release.sizeBytes,
entityId: resource.release.entity?.id,
}}
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
initialUnrecognized={unrecognized} initialUnrecognized={unrecognized}
@@ -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("");
});
});
@@ -1,11 +1,19 @@
import type { AssetVersion, ManualAcquisitionRequest, ManualAcquisitionResult } from "@umt/contracts"; import type { AssetVersion, DownloadSaveTarget, ManualAcquisitionResult } from "@umt/contracts";
import { Bot, Download } from "lucide-react"; import { ArrowDown, ArrowUp, Bot, ChevronDown, Download, Search, X } from "lucide-react";
import { useEffect, useId, useState } from "react"; import { useEffect, useId, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { toast } from "sonner"; 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 { 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 { 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 { Button } from "~/components/ui/button.tsx";
import { import {
Dialog, Dialog,
@@ -16,16 +24,49 @@ import {
DialogTitle, DialogTitle,
} from "~/components/ui/dialog.tsx"; } from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.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 { 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<ManualAcquisitionRequest, "sourceReleaseId">; export type { ManualDownloadOptions } from "./manual-download-model.ts";
/** 调用方传入的资源摘要;缺的字段不显示 */
export interface ManualDownloadResource { export interface ManualDownloadResource {
title: string; title: string;
subtitle?: string | null;
sizeBytes: number | 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({ export function ManualDownloadDialog({
resource, resource,
@@ -42,23 +83,48 @@ export function ManualDownloadDialog({
initialUnrecognized?: boolean; initialUnrecognized?: boolean;
onDelegate?: () => void; onDelegate?: () => void;
}) { }) {
const instances = downloaders.useList();
const formId = useId(); 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 [instanceId, setInstanceId] = useState("");
const [savePath, setSavePath] = useState(""); const [dir, setDir] = useState("");
const [custom, setCustom] = useState("");
const [mode, setMode] = useState<"normal" | "replace">("normal"); const [mode, setMode] = useState<"normal" | "replace">("normal");
const [selected, setSelected] = useState<string[]>([]); const [selected, setSelected] = useState<string[]>([]);
const [unrecognized, setUnrecognized] = useState(initialUnrecognized); const [unrecognized, setUnrecognized] = useState(initialUnrecognized);
const [identityOpen, setIdentityOpen] = useState(!entityId);
// null = 没动过,显示作品当前的值
const [tmdbType, setTmdbType] = useState<TmdbType | null>(null);
const [tmdbId, setTmdbId] = useState<string | null>(null);
const [picked, setPicked] = useState<{ id: string; title: string } | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [result, setResult] = useState<ManualAcquisitionResult | null>(null); const [result, setResult] = useState<ManualAcquisitionResult | null>(null);
const versions = new Map<string, AssetVersion>();
for (const v of inventory.data?.movie?.versions ?? []) versions.set(v.assetId, v); const entity = detail.data?.entity;
for (const season of inventory.data?.seasons ?? []) const tmdbIdentity = detail.data?.identities.find(
for (const episode of season.episodes) for (const v of episode.versions) versions.set(v.assetId, v); (i) => i.provider === "tmdb" && i.status === "accepted" && !i.contentUnitId,
for (const v of inventory.data?.unplaced ?? []) versions.set(v.assetId, v); );
const existing = [...versions.values()]; 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 single = existing.length === 1 ? existing[0]!.assetId : null;
const effectiveMode = changed ? "normal" : mode;
useEffect(() => { useEffect(() => {
if (mode === "replace" && single) setSelected([single]); if (mode === "replace" && single) setSelected([single]);
}, [mode, single]); }, [mode, single]);
@@ -66,21 +132,32 @@ export function ManualDownloadDialog({
if (open) { if (open) {
setResult(null); setResult(null);
setUnrecognized(initialUnrecognized); setUnrecognized(initialUnrecognized);
if (initialUnrecognized) setIdentityOpen(true);
} }
}, [open, initialUnrecognized]); }, [open, initialUnrecognized]);
const go = async () => { const go = async () => {
setBusy(true); setBusy(true);
try { try {
const r = await submit({ const r = await submit(
...(resource.entityId ? { entityId: resource.entityId } : {}), buildManualOptions({
...(instanceId ? { downloaderInstanceId: instanceId } : {}), entityId,
...(savePath.trim() ? { savePath: savePath.trim() } : {}), currentTmdb,
...(unrecognized ? { allowUnrecognized: true } : {}), identity: choice,
importMode: mode, instanceId,
replacementAssetIds: mode === "replace" ? selected : [], target,
}); dir,
custom,
allowUnrecognized: unrecognized,
mode: effectiveMode,
selected,
}),
);
setResult(r); setResult(r);
if (r.requiresConfirmation) setUnrecognized(true); if (r.requiresConfirmation) {
setUnrecognized(true);
setIdentityOpen(true);
}
if (r.submitted) toast.success("已提交下载"); if (r.submitted) toast.success("已提交下载");
} catch (e) { } catch (e) {
toast.error(errorMessage(e)); toast.error(errorMessage(e));
@@ -88,89 +165,102 @@ export function ManualDownloadDialog({
setBusy(false); 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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl"> <DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>下载这个资源</DialogTitle> <div className="flex min-w-0 items-start gap-3">
<DialogDescription className="break-all"> {work ? (
{resource.title} <CoverPlaceholder
{resource.sizeBytes !== null ? ` · ${formatBytes(resource.sizeBytes, 1)}` : ""} title={work.title}
</DialogDescription> kind={work.kind}
posterPath={posterPathOf(entity?.metadata)}
small
className="h-20 w-14"
/>
) : (
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-full border border-border text-muted-foreground">
<Download className="h-4 w-4" />
</span>
)}
<div className="min-w-0 space-y-1">
<DialogTitle className="break-words">
{work ? `${work.title}${work.year ? ` (${work.year})` : ""}` : "下载这个资源"}
</DialogTitle>
<DialogDescription>
{[siteName, work ? (MEDIA_KIND[work.kind as keyof typeof MEDIA_KIND] ?? null) : "未识别作品"]
.filter(Boolean)
.join(" · ")}
</DialogDescription>
</div>
</div>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="min-w-0 space-y-4">
<FilterSelect <ResourceSummary resource={resource} />
label="下载器" <SaveTargetFields
value={instanceId} formId={formId}
onChange={setInstanceId} instances={paths.data?.instances ?? []}
allLabel="默认下载器" autoInstanceId={paths.data?.autoInstanceId ?? null}
options={(instances.data?.items ?? []) loading={paths.isPending}
.filter((v) => v.enabled) error={paths.error ? errorMessage(paths.error) : null}
.map((v) => ({ value: v.id, label: v.name }))} instanceId={instanceId}
onInstanceChange={(v) => {
setInstanceId(v);
if (dir !== CUSTOM_DIR) setDir("");
}}
target={target}
dir={dir}
onDirChange={setDir}
custom={custom}
onCustomChange={setCustom}
/> />
<label htmlFor={`${formId}-path`} className="block space-y-1 text-caption"> <IdentitySection
保存目录 formId={formId}
<Input open={identityOpen}
id={`${formId}-path`} onToggle={() => setIdentityOpen((v) => !v)}
value={savePath} current={work && entityId ? { ...work, tmdbId: currentTmdb?.id ?? null } : null}
onChange={(e) => setSavePath(e.target.value)} unrecognized={!entityId || unrecognized}
placeholder="留空使用默认下载目录" type={shownType}
onTypeChange={(v) => {
setTmdbType(v);
setPicked(null);
}}
tmdbId={shownId}
onTmdbIdChange={(v) => {
setTmdbId(v.trim());
setPicked(null);
}}
picked={picked}
onPick={(w) => {
if ("mediaEntityId" in w) setPicked({ id: w.mediaEntityId, title: w.title });
else {
setPicked(null);
setTmdbType(w.tmdb.type);
setTmdbId(w.tmdb.id);
}
}}
onClearPick={() => setPicked(null)}
changed={changed}
subject={resource.title}
/>
{entityId && !changed ? (
<ExistingVersionsSection
formId={formId}
loading={inventory.isPending}
error={!!inventory.error}
summary={versionSummaryText(inventory.data?.summary)}
versions={existing}
mode={mode}
onModeChange={setMode}
selected={selected}
onSelectedChange={setSelected}
/> />
</label>
<FilterSelect
label="入库方式"
value={mode === "normal" ? "" : mode}
onChange={(v) => setMode(v === "replace" ? "replace" : "normal")}
allLabel="正常入库,保留已有版本"
options={[{ value: "replace", label: "一次性洗版入库" }]}
/>
{resource.entityId && inventory.isPending ? (
<p className="text-caption text-muted-foreground">正在读取已有版本…</p>
) : null}
{inventory.error ? (
<p className="text-caption text-destructive">已有版本读取失败,请重试后选择洗版。</p>
) : null}
{existing.length ? (
<fieldset className="max-h-56 space-y-2 overflow-auto rounded-lg border p-3">
<legend className="px-1 text-caption">
{mode === "replace" ? "选择要替换的已有文件" : "媒体库已有版本"}
</legend>
{existing.map((v) => (
<div key={v.assetId} className="flex items-start gap-2 text-caption">
{mode === "replace" ? (
<input
type="checkbox"
id={`${formId}-${v.assetId}`}
checked={selected.includes(v.assetId)}
onChange={(e) =>
setSelected((ids) =>
e.target.checked ? [...ids, v.assetId] : ids.filter((id) => id !== v.assetId),
)
}
/>
) : null}
{mode === "replace" ? (
<label htmlFor={`${formId}-${v.assetId}`} className="min-w-0 break-all">
<VersionDescription version={v} />
</label>
) : (
<VersionDescription version={v} />
)}
</div>
))}
</fieldset>
) : mode === "replace" ? (
<p className="text-caption text-muted-foreground">没有可替换的已有文件,请选择正常入库。</p>
) : null}
{mode === "replace" ? (
<p className="text-meta text-muted-foreground">
本次新文件成功入库后替换所选旧文件。剧集只替换成功对应的季集,其余保留。
</p>
) : null}
{unrecognized ? (
<p className="rounded-lg border border-warning/40 p-3 text-caption text-warning">
暂未识别到作品。可以继续下载;整理时会尝试补全识别和本地元数据。
</p>
) : null} ) : null}
{result && !result.submitted && !result.requiresConfirmation ? ( {result && !result.submitted && !result.requiresConfirmation ? (
<p className="text-caption text-warning">{result.rejected.map((r) => r.message).join(";")}</p> <p className="text-caption text-warning">{result.rejected.map((r) => r.message).join(";")}</p>
@@ -200,11 +290,13 @@ export function ManualDownloadDialog({
disabled={ disabled={
busy || busy ||
!!result?.submitted || !!result?.submitted ||
(mode === "replace" && (!selected.length || inventory.isPending || !!inventory.error)) missingCustom ||
badTmdb ||
(effectiveMode === "replace" && (!selected.length || inventory.isPending || !!inventory.error))
} }
> >
<Download /> <Download />
{busy ? "提交中…" : unrecognized ? "继续下载" : "提交下载"} {busy ? "提交中…" : unrecognized && !changed ? "继续下载" : "开始下载"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -212,12 +304,363 @@ export function ManualDownloadDialog({
); );
} }
/** 资源行:原始标题、促销、H&R、做种 / 下载、大小、发布时间 */
export function ResourceSummary({ resource: r }: { resource: ManualDownloadResource }) {
return (
<div className="min-w-0 space-y-1.5 rounded-xl border border-border p-3">
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-1">
<p className="min-w-0 flex-1 text-sm font-medium break-all text-foreground">{r.title}</p>
<span className="flex flex-wrap gap-1">
{r.promotion ? (
<StatusBadge tone={r.promotion.free ? "success" : "neutral"}>{r.promotion.label}</StatusBadge>
) : null}
{r.hr === "yes" ? <StatusBadge tone="warning">H&R</StatusBadge> : null}
{r.hr === "unknown" ? <StatusBadge>H&R 未知</StatusBadge> : null}
</span>
</div>
{r.subtitle ? <p className="line-clamp-2 text-caption text-muted-foreground">{r.subtitle}</p> : null}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums">
<span>{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}</span>
{r.seeders !== undefined || r.leechers !== undefined ? (
<span className="inline-flex items-center gap-0.5" title="做种 / 下载">
<ArrowUp className="h-3 w-3" aria-label="做种" />
{r.seeders ?? "—"}
<ArrowDown className="ml-1 h-3 w-3" aria-label="下载" />
{r.leechers ?? "—"}
</span>
) : null}
{r.publishedAt ? <span title={absTime(r.publishedAt)}>发布 {relTime(r.publishedAt)}</span> : null}
</div>
</div>
);
}
/** 下载器 + 保存目录并排;下方写明实际会保存到哪 */
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 (
<div className="grid items-start gap-x-3 sm:grid-cols-2">
<FormField label="下载器" htmlFor={`${formId}-dl`} hint={!instanceId && auto ? `自动:${auto.name}` : undefined}>
<Select value={instanceId || AUTO} onValueChange={(v) => onInstanceChange(v === AUTO ? "" : v)}>
<SelectTrigger id={`${formId}-dl`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value={AUTO}>自动</SelectItem>
{instances.map((t) => (
<SelectItem key={t.instanceId} value={t.instanceId}>
{t.name}
{t.isDefault ? "(默认)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField label="保存目录" htmlFor={`${formId}-dir`} hint={pathHint} error={error}>
<Select value={dir || AUTO} onValueChange={(v) => onDirChange(v === AUTO ? "" : v)}>
<SelectTrigger id={`${formId}-dir`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value={AUTO}>自动</SelectItem>
{(target?.dirs ?? []).map((d) => (
<SelectItem key={d.path} value={d.path}>
{d.path}
{d.isDefault ? "(默认)" : ""}
</SelectItem>
))}
<SelectItem value={CUSTOM_DIR}>自定义路径…</SelectItem>
</SelectContent>
</Select>
</FormField>
{dir === CUSTOM_DIR ? (
<FormField
label="自定义路径"
htmlFor={`${formId}-custom`}
hint="下载器路径或本机路径都行,本机路径按路径映射换算"
className="sm:col-span-2"
>
<Input
id={`${formId}-custom`}
value={custom}
onChange={(e) => onCustomChange(e.target.value)}
placeholder="/downloads/movies"
/>
</FormField>
) : null}
</div>
);
}
/** 作品身份:类型 + 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 (
<section className="min-w-0 rounded-xl border border-border">
<button
type="button"
onClick={onToggle}
aria-expanded={open}
aria-controls={`${formId}-identity`}
className="flex w-full cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-left transition-colors duration-micro hover:bg-wash/[0.06]"
>
<span className="shrink-0 text-caption font-medium text-foreground">作品</span>
<span
className={cn(
"min-w-0 flex-1 truncate text-caption",
changed ? "text-foreground" : current ? "text-muted-foreground" : "text-warning",
)}
>
{changed
? `改为:${picked ? picked.title : `${type === "movie" ? "电影" : "剧集"} · TMDB ${tmdbId}`}`
: summary}
</span>
<span className="shrink-0 text-meta text-muted-foreground">{open ? "收起" : "修改"}</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-base",
open && "rotate-180",
)}
/>
</button>
{open ? (
<div id={`${formId}-identity`} className="space-y-3 border-t border-border px-3 pt-3 pb-1">
{unrecognized && !changed ? (
<p className="text-caption text-warning">
暂未识别到作品。指定后绑定到这个资源;不指定也能下载,整理时再补全识别和元数据。
</p>
) : null}
{picked ? (
<div className="flex min-w-0 items-center gap-2 pb-3 text-sm">
<span className="min-w-0 flex-1 truncate text-foreground">
{picked.title}
<span className="text-muted-foreground"> · 在媒体库</span>
</span>
<Button type="button" variant="ghost" size="icon-sm" aria-label="清除选择" onClick={onClearPick}>
<X />
</Button>
</div>
) : (
<div className="grid items-start gap-x-3 sm:grid-cols-[10rem_1fr]">
<FormField label="类型" htmlFor={`${formId}-kind`}>
<Select value={type} onValueChange={(v) => onTypeChange(v as TmdbType)}>
<SelectTrigger id={`${formId}-kind`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="movie">电影</SelectItem>
<SelectItem value="tv">剧集</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField
label="TMDB 编号"
htmlFor={`${formId}-tmdb`}
error={tmdbId && !valid ? "TMDB 编号是数字" : preview.error ? errorMessage(preview.error) : null}
hint={
preview.data
? `${preview.data.title}${preview.data.year ? ` (${preview.data.year})` : ""}${preview.data.localEntityId ? " · 在媒体库" : ""}`
: preview.isFetching
? "查询中…"
: changed
? undefined
: "输入编号,或搜索作品名"
}
>
<div className="flex gap-2">
<Input
id={`${formId}-tmdb`}
inputMode="numeric"
value={tmdbId}
onChange={(e) => onTmdbIdChange(e.target.value)}
placeholder="例如 969681"
aria-invalid={!!tmdbId && !valid}
/>
<Button
type="button"
variant="outline"
size="icon"
aria-label="搜索作品"
title="搜索作品"
onClick={() => setSearching(true)}
>
<Search />
</Button>
</div>
</FormField>
</div>
)}
</div>
) : null}
<WorkAssignDialog
open={searching}
onOpenChange={setSearching}
initialQuery={current?.title ?? ""}
subject={subject}
onAssign={async (w) => onPick(w)}
/>
</section>
);
}
/** 已有版本:先一行摘要;选一次性洗版时展开勾选要替换的文件 */
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 <p className="text-caption text-muted-foreground">正在读取已有版本…</p>;
if (error) return <p className="text-caption text-destructive">已有版本读取失败,重试后再选洗版</p>;
if (!versions.length) return null;
return (
<div className="min-w-0 space-y-2">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<p className="min-w-0 text-caption text-foreground">{summary ?? `媒体库已有 ${versions.length} 个版本`}</p>
<FilterSelect
label="入库方式"
value={mode === "normal" ? "" : mode}
onChange={(v) => onModeChange(v === "replace" ? "replace" : "normal")}
allLabel="正常入库,保留已有版本"
options={[{ value: "replace", label: "一次性洗版" }]}
/>
</div>
{mode === "replace" ? (
<>
<fieldset className="max-h-56 space-y-2 overflow-auto rounded-lg border border-border p-3">
<legend className="px-1 text-caption">选择要替换的已有文件</legend>
{versions.map((v) => (
<div key={v.assetId} className="flex items-start gap-2 text-caption">
<input
type="checkbox"
id={`${formId}-${v.assetId}`}
checked={selected.includes(v.assetId)}
onChange={(e) =>
onSelectedChange(
e.target.checked ? [...selected, v.assetId] : selected.filter((id) => id !== v.assetId),
)
}
/>
<label htmlFor={`${formId}-${v.assetId}`} className="min-w-0 break-all">
<VersionDescription version={v} />
</label>
</div>
))}
</fieldset>
<p className="text-meta text-muted-foreground">
本次新文件成功入库后替换所选旧文件。剧集只替换成功对应的季集,其余保留。
</p>
</>
) : null}
</div>
);
}
function VersionDescription({ version }: { version: AssetVersion }) { function VersionDescription({ version }: { version: AssetVersion }) {
return ( return (
<span className="min-w-0 break-all"> <span className="min-w-0 break-all">
{version.fileName} {version.fileName}
<span className="block text-meta text-muted-foreground"> <span className="block text-meta text-muted-foreground">
{[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) .filter(Boolean)
.join(" · ")} .join(" · ")}
</span> </span>
@@ -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");
@@ -1,7 +1,9 @@
import type { SearchRelease } from "@umt/contracts"; import type { SearchRelease } from "@umt/contracts";
import { usePlanFromSearch } from "~/api/search.ts"; import { usePlanFromSearch } from "~/api/search.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.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) { export function delegateToAgent(release: SearchRelease) {
openAgentDrawer({ 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({ export function SearchDownloadDialog({
release, release,
open, open,
@@ -28,11 +51,7 @@ export function SearchDownloadDialog({
key={`${release.siteId}:${release.siteReleaseId}`} key={`${release.siteId}:${release.siteReleaseId}`}
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
resource={{ resource={searchResource(release)}
title: release.title,
sizeBytes: release.sizeBytes,
entityId: release.entityMatch === "resolved" ? release.entity?.id : undefined,
}}
onDelegate={() => delegateToAgent(release)} onDelegate={() => delegateToAgent(release)}
submit={(options) => submit={(options) =>
submit.mutateAsync({ ...options, siteId: release.siteId, siteReleaseId: release.siteReleaseId }) submit.mutateAsync({ ...options, siteId: release.siteId, siteReleaseId: release.siteReleaseId })
@@ -1,7 +1,28 @@
import type { AcquisitionSlot, WorkbenchRelease } from "@umt/contracts"; import type { AcquisitionSlot, WorkbenchRelease } from "@umt/contracts";
import { useSubmitRelease } from "~/api/releases.ts"; import { useSubmitRelease } from "~/api/releases.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.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({ export function DownloadDialog({
@@ -20,7 +41,7 @@ export function DownloadDialog({
key={release.id} key={release.id}
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
resource={{ title: release.title, sizeBytes: release.sizeBytes, entityId: release.entity?.id }} resource={workbenchResource(release)}
onDelegate={() => onDelegate={() =>
openAgentDrawer({ openAgentDrawer({
label: `资源:${release.title}`, label: `资源:${release.title}`,
+8
View File
@@ -64,6 +64,14 @@ export const executionApi = {
body: execution.ManualAcquisitionRequest, body: execution.ManualAcquisitionRequest,
response: execution.ManualAcquisitionResult, response: execution.ManualAcquisitionResult,
}), }),
savePaths: ep({
method: "get",
path: "/download-save-paths",
scope: "read",
summary: "手动下载可选的下载器与保存目录(默认保存目录 + 路径映射),以及「自动」会用哪个下载器",
query: execution.DownloadSavePathQuery,
response: execution.DownloadSavePaths,
}),
why: ep({ why: ep({
method: "get", method: "get",
path: "/acquisition-plans/{id}/why", path: "/acquisition-plans/{id}/why",
+38
View File
@@ -1,5 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common.ts"; import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common.ts";
import { ExternalRef } from "./identity.ts";
import { AcquisitionPriority } from "./policy.ts"; import { AcquisitionPriority } from "./policy.ts";
// 执行域:业务任务、下载计划、下载任务、操作账本(REQ §8.4、§11;ADR-002) // 执行域:业务任务、下载计划、下载任务、操作账本(REQ §8.4、§11;ADR-002)
@@ -146,6 +147,13 @@ export const ManualAcquisitionRequest = z.object({
allowUnrecognized: z.boolean().optional(), allowUnrecognized: z.boolean().optional(),
importMode: z.enum(["normal", "replace"]).default("normal"), importMode: z.enum(["normal", "replace"]).default("normal"),
replacementAssetIds: z.array(Id).default([]), 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<typeof ManualAcquisitionRequest>; export type ManualAcquisitionRequest = z.infer<typeof ManualAcquisitionRequest>;
@@ -161,6 +169,36 @@ export const ManualAcquisitionResult = z.object({
rejected: z.array(z.object({ slotId: Id.nullable(), code: z.string(), message: z.string() })), rejected: z.array(z.object({ slotId: Id.nullable(), code: z.string(), message: z.string() })),
}); });
export type ManualAcquisitionResult = z.infer<typeof ManualAcquisitionResult>; export type ManualAcquisitionResult = z.infer<typeof ManualAcquisitionResult>;
/** 手动下载可选的保存目录:按下载器列出默认保存目录与路径映射的下载器侧目录 */
export const DownloadSavePathQuery = z.object({
/** 资源所在站点;站点指定了下载器时「自动」按它解析 */
siteId: Id.optional(),
});
export type DownloadSavePathQuery = z.infer<typeof DownloadSavePathQuery>;
export const DownloadSaveDir = z.object({
/** 下载器视角的路径(提交给下载器的保存路径) */
path: z.string(),
/** 本机视角的路径;配置了映射但对不上时为 null */
hostPath: z.string().nullable(),
/** 下载器设置里的默认保存目录(「自动」用它) */
isDefault: z.boolean(),
});
export type DownloadSaveDir = z.infer<typeof DownloadSaveDir>;
export const DownloadSaveTarget = z.object({
instanceId: Id,
name: z.string(),
isDefault: z.boolean(),
/** 第一个是默认保存目录 */
dirs: z.array(DownloadSaveDir),
});
export type DownloadSaveTarget = z.infer<typeof DownloadSaveTarget>;
export const DownloadSavePaths = z.object({
/** 不指定下载器时会用的那个(站点指定 > 默认下载器 > 第一个有保存目录的);没有可用下载器为 null */
autoInstanceId: Id.nullable(),
instances: z.array(DownloadSaveTarget),
});
export type DownloadSavePaths = z.infer<typeof DownloadSavePaths>;
export const AcquisitionPlanUpdate = AcquisitionPlan.pick({ status: true, taskId: true, statusReason: true }).partial(); export const AcquisitionPlanUpdate = AcquisitionPlan.pick({ status: true, taskId: true, statusReason: true }).partial();
export type AcquisitionPlanUpdate = z.infer<typeof AcquisitionPlanUpdate>; export type AcquisitionPlanUpdate = z.infer<typeof AcquisitionPlanUpdate>;