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; 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([]); 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 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 (
{work ? ( ) : ( )}
{work ? `${work.title}${work.year ? ` (${work.year})` : ""}` : "下载这个资源"} {[siteName, work ? (MEDIA_KIND[work.kind as keyof typeof MEDIA_KIND] ?? null) : "未识别作品"] .filter(Boolean) .join(" · ")}
{ setInstanceId(v); if (dir !== CUSTOM_DIR) setDir(""); }} target={target} dir={dir} onDirChange={setDir} custom={custom} onCustomChange={setCustom} /> 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 ? ( ) : null} {result && !result.submitted && !result.requiresConfirmation ? (

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

) : null} {result?.submitted ? ( 查看下载任务 ) : null}
{onDelegate ? ( ) : null}
); } /** 资源行:原始标题、促销、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.sizeBytes ? formatBytes(version.sizeBytes, 1) : null, ] .filter(Boolean) .join(" · ")} ); }