Files
UHD_Media_Tool/apps/web/app/components/downloads/manual-download-dialog.tsx
T

670 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
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 { 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 } from "./manual-download-model.ts";
/** 调用方传入的资源摘要;缺的字段不显示 */
export interface ManualDownloadResource {
title: string;
subtitle?: string | null;
sizeBytes: number | null;
/** 已识别到的作品 */
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,
open,
onOpenChange,
submit,
initialUnrecognized = false,
onDelegate,
}: {
resource: ManualDownloadResource;
open: boolean;
onOpenChange: (open: boolean) => void;
submit: (options: ManualDownloadOptions) => Promise<ManualAcquisitionResult>;
initialUnrecognized?: boolean;
onDelegate?: () => void;
}) {
const formId = useId();
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 [dir, setDir] = useState("");
const [custom, setCustom] = useState("");
const [mode, setMode] = useState<"normal" | "replace">("normal");
const [selected, setSelected] = useState<string[]>([]);
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 [result, setResult] = useState<ManualAcquisitionResult | null>(null);
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]);
useEffect(() => {
if (open) {
setResult(null);
setUnrecognized(initialUnrecognized);
if (initialUnrecognized) setIdentityOpen(true);
}
}, [open, initialUnrecognized]);
const go = async () => {
setBusy(true);
try {
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);
setIdentityOpen(true);
}
if (r.submitted) toast.success("已提交下载");
} catch (e) {
toast.error(errorMessage(e));
} finally {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<div className="flex min-w-0 items-start gap-3">
{work ? (
<CoverPlaceholder
title={work.title}
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>
<div className="min-w-0 space-y-4">
<ResourceSummary resource={resource} />
<SaveTargetFields
formId={formId}
instances={paths.data?.instances ?? []}
autoInstanceId={paths.data?.autoInstanceId ?? null}
loading={paths.isPending}
error={paths.error ? errorMessage(paths.error) : null}
instanceId={instanceId}
onInstanceChange={(v) => {
setInstanceId(v);
if (dir !== CUSTOM_DIR) setDir("");
}}
target={target}
dir={dir}
onDirChange={setDir}
custom={custom}
onCustomChange={setCustom}
/>
<IdentitySection
formId={formId}
open={identityOpen}
onToggle={() => setIdentityOpen((v) => !v)}
current={work && entityId ? { ...work, tmdbId: currentTmdb?.id ?? null } : null}
unrecognized={!entityId || unrecognized}
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}
/>
) : null}
{result && !result.submitted && !result.requiresConfirmation ? (
<p className="text-caption text-warning">{result.rejected.map((r) => r.message).join(";")}</p>
) : null}
{result?.submitted ? (
<Link className="text-caption underline" to={`/console/downloads?plan=${result.plan?.id ?? ""}`}>
查看下载任务
</Link>
) : null}
</div>
<DialogFooter>
{onDelegate ? (
<Button
variant="outline"
disabled={busy}
onClick={() => {
onOpenChange(false);
onDelegate();
}}
>
<Bot />
交给智能体
</Button>
) : null}
<Button
onClick={() => void go()}
disabled={
busy ||
!!result?.submitted ||
missingCustom ||
badTmdb ||
(effectiveMode === "replace" && (!selected.length || inventory.isPending || !!inventory.error))
}
>
<Download />
{busy ? "提交中…" : unrecognized && !changed ? "继续下载" : "开始下载"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** 资源行:原始标题、促销、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 }) {
return (
<span className="min-w-0 break-all">
{version.fileName}
<span className="block text-meta text-muted-foreground">
{[
version.resolution,
version.source,
version.videoCodec,
...version.hdr,
version.edition,
version.sizeBytes ? formatBytes(version.sizeBytes, 1) : null,
]
.filter(Boolean)
.join(" · ")}
</span>
</span>
);
}