feat(subtitles): 字幕站(SubHD、字幕库)在线搜索与下载;rar / 7z 解压;验证码与页面解析在沙箱里

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 02:28:29 +08:00
1 parent 92d456d123
commit 59b3680f35
32 files changed
+1704 -166

No files matched your search

@@ -1,10 +1,18 @@
import type { SubtitleLanguage, SubtitleParsedItem, SubtitleTarget, SubtitleWriteView } from "@umt/contracts";
import { FileUp, LoaderCircle, Undo2 } from "lucide-react";
import type {
SubtitleLanguage,
SubtitleParsedItem,
SubtitleSearchResult,
SubtitleTarget,
SubtitleWriteView,
} from "@umt/contracts";
import { Download, ExternalLink, FileUp, LoaderCircle, Search, Undo2 } from "lucide-react";
import { useId, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
useApplySubtitles,
useFetchSubtitle,
useParseSubtitles,
useSearchSubtitles,
useSubtitleTargets,
useSubtitleWrites,
useUndoSubtitle,
@@ -20,7 +28,9 @@ import {
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 { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
@@ -74,6 +84,7 @@ export function SubtitleDialog({
const [rows, setRows] = useState<Row[]>([]);
const [skipped, setSkipped] = useState<{ name: string; reason: string }[]>([]);
const [dragging, setDragging] = useState(false);
const [tab, setTab] = useState<"upload" | "online">("upload");
const list = targets.data?.targets ?? [];
const busy = parse.isPending || apply.isPending;
@@ -112,6 +123,8 @@ export function SubtitleDialog({
language: x.language,
content: x.content,
targetLocationId: x.target,
...(x.source ? { source: x.source } : {}),
...(x.sourceRef ? { sourceRef: x.sourceRef } : {}),
})),
});
if (r.written.length) toast.success(`已写入 ${r.written.length} 个字幕`);
@@ -141,41 +154,58 @@ export function SubtitleDialog({
<EmptyState title="媒体库里还没有这部作品的视频" description="整理入库后才能补字幕。" />
) : (
<div className="min-w-0 space-y-4">
<label
htmlFor={inputId}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
void load([...e.dataTransfer.files]);
}}
className={cn(
"flex cursor-pointer flex-col items-center gap-1 rounded-lg border border-dashed border-border px-4 py-6 text-center transition-colors hover:border-foreground/40",
dragging && "border-foreground/60 bg-accent",
)}
>
{parse.isPending ? <LoaderCircle className="size-5 animate-spin" /> : <FileUp className="size-5" />}
<span className="text-caption text-foreground">选择或拖入字幕文件</span>
<span className="text-meta text-muted-foreground">
支持 srt / ass / ssa / vtt / sub / sbv 和 zip,可一次选多个
</span>
<input
ref={input}
id={inputId}
type="file"
multiple
accept={ACCEPT}
className="sr-only"
onChange={(e) => {
void load([...(e.target.files ?? [])]);
if (input.current) input.current.value = "";
<Tabs value={tab} onValueChange={(v) => setTab(v as "upload" | "online")}>
<TabsList>
<TabsTrigger value="upload">上传</TabsTrigger>
<TabsTrigger value="online">在线搜索</TabsTrigger>
</TabsList>
</Tabs>
{tab === "upload" ? (
<label
htmlFor={inputId}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
void load([...e.dataTransfer.files]);
}}
className={cn(
"flex cursor-pointer flex-col items-center gap-1 rounded-lg border border-dashed border-border px-4 py-6 text-center transition-colors hover:border-foreground/40",
dragging && "border-foreground/60 bg-accent",
)}
>
{parse.isPending ? <LoaderCircle className="size-5 animate-spin" /> : <FileUp className="size-5" />}
<span className="text-caption text-foreground">选择或拖入字幕文件</span>
<span className="text-meta text-muted-foreground">
支持 srt / ass / ssa / vtt / sub / sbv 和 zip,可一次选多个
</span>
<input
ref={input}
id={inputId}
type="file"
multiple
accept={ACCEPT}
className="sr-only"
onChange={(e) => {
void load([...(e.target.files ?? [])]);
if (input.current) input.current.value = "";
}}
/>
</label>
) : (
<OnlinePanel
entityId={entityId}
seasons={[...new Set(list.map((t) => t.season).filter((x): x is number => x !== null))]}
onFetched={(items, skip) => {
setRows((prev) => [...prev, ...items.map((it) => ({ ...it, target: it.targetLocationId ?? SKIP }))]);
setSkipped((prev) => [...prev, ...skip]);
}}
/>
</label>
)}
{rows.length ? (
<ul className="space-y-2">
@@ -318,3 +348,145 @@ function History({
</section>
);
}
/** 在线搜索:已启用的字幕站(SubHD、字幕库);下载的字幕进上面同一个预览 */
function OnlinePanel({
entityId,
seasons,
onFetched,
}: {
entityId: string;
seasons: number[];
onFetched: (items: SubtitleParsedItem[], skipped: { name: string; reason: string }[]) => void;
}) {
const search = useSearchSubtitles();
const fetch = useFetchSubtitle();
const [keyword, setKeyword] = useState("");
const [season, setSeason] = useState<string>(seasons.length === 1 ? String(seasons[0]) : "all");
const [episode, setEpisode] = useState("");
const [fetching, setFetching] = useState<string | null>(null);
const data = search.data;
const run = () =>
search.mutate(
{
entityId,
...(keyword.trim() ? { keyword: keyword.trim() } : {}),
...(season !== "all" ? { season: Number(season) } : {}),
...(episode.trim() && /^\d+$/.test(episode.trim()) ? { episode: Number(episode.trim()) } : {}),
},
{ onError: (err) => toast.error(errorMessage(err, "搜索失败")) },
);
const take = async (r: SubtitleSearchResult) => {
setFetching(`${r.siteId}${r.ref}`);
try {
const p = await fetch.mutateAsync({ entityId, siteId: r.siteId, ref: r.ref, title: r.title });
if (!p.items.length) toast.error("这个字幕包里没有能用的字幕文件");
else toast.success(`已下载 ${p.items.length} 个字幕,检查下面的配对后写入`);
onFetched(p.items, p.skipped);
} catch (err) {
toast.error(errorMessage(err, "下载失败"));
} finally {
setFetching(null);
}
};
return (
<div className="min-w-0 space-y-3">
<form
className="flex flex-wrap items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
run();
}}
>
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="默认按作品名搜,可另填关键词"
aria-label="关键词"
className="min-w-48 flex-1"
/>
{seasons.length ? (
<>
<Select value={season} onValueChange={setSeason}>
<SelectTrigger aria-label="季" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="all">全部季</SelectItem>
{seasons.map((n) => (
<SelectItem key={n} value={String(n)}>
第 {n} 季
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={episode}
onChange={(e) => setEpisode(e.target.value)}
placeholder="集(可空)"
aria-label="集"
inputMode="numeric"
className="w-24"
/>
</>
) : null}
<Button type="submit" disabled={search.isPending}>
{search.isPending ? <LoaderCircle className="animate-spin" /> : <Search />}
搜索
</Button>
</form>
{data?.noSites ? (
<p className="text-caption text-muted-foreground">
还没有启用的字幕站。到「站点 → 添加站点 → 字幕站」添加 SubHD 或字幕库。
</p>
) : null}
{data?.errors.length ? (
<p className="text-meta text-warning">{data.errors.map((e) => `${e.siteName}:${e.reason}`).join(";")}</p>
) : null}
{data && !data.noSites && !data.results.length ? (
<p className="text-caption text-muted-foreground">
没搜到(用了:{data.keywords.join("、") || "无"})。可以换个关键词,或去掉季集限制再试。
</p>
) : null}
{data?.results.length ? (
<ul className="max-h-80 space-y-1.5 overflow-y-auto">
{data.results.map((r) => {
const key = `${r.siteId}${r.ref}`;
return (
<li key={key} className="flex min-w-0 items-center gap-2 rounded-lg border border-border/70 px-3 py-2">
<div className="min-w-0 flex-1">
<p className="truncate text-caption text-foreground" title={r.title}>
{r.title}
</p>
<p className="truncate text-meta text-muted-foreground">
{[
r.siteName,
r.languages.join(" / ") || null,
r.format,
r.downloads !== null ? `下载 ${r.downloads}` : null,
r.publishedAt ? (r.publishedAt.includes("T") ? relTime(r.publishedAt) : r.publishedAt) : null,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
<Button asChild variant="ghost" size="icon-sm" title="在字幕站打开">
<a href={r.pageUrl} target="_blank" rel="noreferrer" aria-label="在字幕站打开">
<ExternalLink />
</a>
</Button>
<Button size="sm" variant="outline" disabled={fetching !== null} onClick={() => void take(r)}>
{fetching === key ? <LoaderCircle className="animate-spin" /> : <Download />}
下载
</Button>
</li>
);
})}
</ul>
) : null}
</div>
);
}