Files
silo-server/web/src/pages/ItemDetail/components/SubtitleSearchDialog.tsx
T

464 lines
17 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, Download, Ear, Loader2, Search } from "lucide-react";
import type { FileVersion, SubtitleResult } from "@/api/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
searchSubtitles,
detectSubtitleLanguage,
useDownloadSubtitle,
useDownloadedSubtitles,
useUploadSubtitle,
} from "@/hooks/queries/subtitles";
import { cn } from "@/lib/utils";
import { LANGUAGES, getLanguageName } from "@/player/utils/languageNames";
import { SubtitleUploadForm } from "@/components/subtitles/SubtitleUploadForm";
import { buildQualitySummary } from "./VersionFlyout";
interface SubtitleSearchDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
version: FileVersion | null;
title: string;
}
const providerInfo: Record<string, { abbr: string; className: string }> = {
opensubtitles: { abbr: "OS", className: "bg-amber-500/15 text-amber-700 dark:text-amber-300" },
subdl: { abbr: "SDL", className: "bg-sky-500/15 text-sky-700 dark:text-sky-300" },
subsource: { abbr: "SS", className: "bg-rose-500/15 text-rose-700 dark:text-rose-300" },
upload: { abbr: "UP", className: "bg-violet-500/15 text-violet-700 dark:text-violet-300" },
};
function scoreTone(score: number): { text: string; ring: string; bg: string } {
if (score >= 70) {
return {
text: "text-emerald-600 dark:text-emerald-300",
ring: "ring-emerald-500/30 dark:ring-emerald-400/30",
bg: "bg-emerald-500/10 dark:bg-emerald-400/10",
};
}
if (score >= 40) {
return {
text: "text-amber-600 dark:text-amber-300",
ring: "ring-amber-500/30 dark:ring-amber-400/30",
bg: "bg-amber-500/10 dark:bg-amber-400/10",
};
}
return {
text: "text-rose-600 dark:text-rose-300",
ring: "ring-rose-500/30 dark:ring-rose-400/30",
bg: "bg-rose-500/10 dark:bg-rose-400/10",
};
}
function splitReleaseNames(raw: string): string[] {
return raw
.split(/[\r\n]+/)
.map((s) => s.trim())
.filter(Boolean);
}
export default function SubtitleSearchDialog({
open,
onOpenChange,
version,
title,
}: SubtitleSearchDialogProps) {
const downloadSubtitleMutation = useDownloadSubtitle();
const uploadSubtitleMutation = useUploadSubtitle();
const downloadedQuery = useDownloadedSubtitles(open ? version?.file_id : undefined);
const searchAbortRef = useRef<AbortController | null>(null);
const [selectedLanguage, setSelectedLanguage] = useState("en");
const [results, setResults] = useState<SubtitleResult[]>([]);
const [warnings, setWarnings] = useState<string[]>([]);
const [searchError, setSearchError] = useState<string | null>(null);
const [searching, setSearching] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(() => new Set());
// Reset state when dialog opens/closes or version changes.
useEffect(() => {
if (!open) {
searchAbortRef.current?.abort();
searchAbortRef.current = null;
setSearching(false);
return;
}
setSelectedLanguage("en");
setResults([]);
setWarnings([]);
setSearchError(null);
setHasSearched(false);
setExpandedKeys(new Set());
}, [open, version?.file_id]);
const toggleExpanded = useCallback((key: string) => {
setExpandedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}, []);
const parsedResults = useMemo(
() =>
results.map((result) => ({
result,
key: `${result.provider}:${result.id}`,
names: splitReleaseNames(result.release_name),
})),
[results],
);
const handleSearch = useCallback(async () => {
if (!version) return;
searchAbortRef.current?.abort();
const controller = new AbortController();
searchAbortRef.current = controller;
setSearching(true);
setHasSearched(true);
setSearchError(null);
setWarnings([]);
try {
const response = await searchSubtitles(
{
media_file_id: version.file_id,
languages: [selectedLanguage],
},
{ signal: controller.signal },
);
if (controller.signal.aborted) return;
setResults(response.results ?? []);
setWarnings(response.warnings ?? []);
} catch (err) {
if (controller.signal.aborted) return;
setResults([]);
setSearchError(err instanceof Error ? err.message : "Search failed");
} finally {
if (!controller.signal.aborted) {
setSearching(false);
}
}
}, [selectedLanguage, version]);
const handleDownload = useCallback(
async (result: SubtitleResult) => {
if (!version) return;
const key = `${result.provider}:${result.id}`;
setDownloadingKey(key);
setSearchError(null);
try {
await downloadSubtitleMutation.mutateAsync({
media_file_id: version.file_id,
provider: result.provider,
subtitle_id: result.id,
language: result.language,
release_name: result.release_name,
format: result.format,
score: result.score,
hearing_impaired: result.hearing_impaired,
});
await downloadedQuery.refetch();
} catch {
// Error is surfaced by the mutation toast.
} finally {
setDownloadingKey(null);
}
},
[downloadSubtitleMutation, downloadedQuery, version],
);
const handleUpload = useCallback(
async (input: {
mediaFileId: number;
file: File;
language?: string;
languageOverride?: boolean;
hearingImpaired: boolean;
}) => {
await uploadSubtitleMutation.mutateAsync({
media_file_id: input.mediaFileId,
file: input.file,
language: input.language,
language_override: input.languageOverride,
hearing_impaired: input.hearingImpaired,
});
},
[uploadSubtitleMutation],
);
const handleDetectLanguage = useCallback(
(file: File, fallbackLanguage?: string) => detectSubtitleLanguage(file, fallbackLanguage),
[],
);
const handleUploadSuccess = useCallback(async () => {
await downloadedQuery.refetch();
}, [downloadedQuery]);
const versionLabel = version ? buildQualitySummary(version) : "";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl overflow-hidden sm:max-w-3xl">
<DialogHeader className="min-w-0">
<DialogTitle>Add Subtitles</DialogTitle>
<DialogDescription className="truncate">
{title}
{versionLabel ? ` \u00B7 ${versionLabel}` : ""}
</DialogDescription>
</DialogHeader>
<TooltipProvider delayDuration={250}>
<div className="min-w-0 space-y-4">
{version && (
<SubtitleUploadForm
mediaFileId={version.file_id}
upload={handleUpload}
detectLanguage={handleDetectLanguage}
onSuccess={handleUploadSuccess}
onError={setSearchError}
defaultLanguage={selectedLanguage}
/>
)}
<div className="space-y-2">
<p className="text-sm font-medium">Search online</p>
<div className="flex flex-col gap-2 sm:flex-row">
<Select value={selectedLanguage} onValueChange={setSelectedLanguage}>
<SelectTrigger className="w-full sm:w-[220px]">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((language) => (
<SelectItem key={language.code} value={language.code}>
{language.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={handleSearch} disabled={!version || searching}>
{searching ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Search className="size-4" />
)}
Search
</Button>
</div>
</div>
{searchError && (
<div className="rounded-lg bg-rose-500/10 px-3 py-2 text-sm text-rose-700 dark:text-rose-300">
{searchError}
</div>
)}
{warnings.length > 0 && (
<div className="space-y-2">
{warnings.map((warning) => (
<div
key={warning}
className="rounded-lg bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-300"
>
{warning}
</div>
))}
</div>
)}
{parsedResults.length > 0 ? (
<div className="-mr-1 max-h-[28rem] min-w-0 space-y-2 overflow-x-hidden overflow-y-auto pr-1">
{parsedResults.map(({ result, key, names }) => {
const provider = providerInfo[result.provider] ?? {
abbr: result.provider.slice(0, 2).toUpperCase(),
className: "bg-muted text-muted-foreground",
};
const tone = scoreTone(result.score);
const score = Math.round(result.score);
const isDownloading = downloadingKey === key;
const expanded = expandedKeys.has(key);
const primary = names[0] ?? result.release_name;
const extras = names.slice(1);
const hasExtras = extras.length > 0;
return (
<div
key={key}
className="border-border/60 bg-accent/20 hover:bg-accent/40 group flex items-stretch gap-3 overflow-hidden rounded-xl border p-3 transition-colors"
>
<div
className={cn(
"flex w-12 shrink-0 flex-col items-center justify-center gap-0.5 rounded-lg ring-1 ring-inset",
tone.bg,
tone.ring,
)}
aria-label={`Match score ${score}`}
>
<span
className={cn(
"text-base leading-none font-semibold tabular-nums",
tone.text,
)}
>
{score}
</span>
<span
className={cn(
"text-[9px] tracking-[0.12em] uppercase opacity-70",
tone.text,
)}
>
Score
</span>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-1.5">
<Badge className={cn("h-5 px-1.5 text-[10px]", provider.className)}>
{provider.abbr}
</Badge>
<Badge variant="outline" className="h-5 px-1.5 text-[10px] uppercase">
{result.format}
</Badge>
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
{getLanguageName(result.language)}
</Badge>
{result.hearing_impaired && (
<Badge variant="outline" className="h-5 gap-1 px-1.5 text-[10px]">
<Ear className="size-2.5" /> HI
</Badge>
)}
{result.downloads > 0 && (
<span className="text-muted-foreground ml-0.5 text-[11px] tabular-nums">
{result.downloads.toLocaleString()}{" "}
{result.downloads === 1 ? "download" : "downloads"}
</span>
)}
</div>
<div className="min-w-0 space-y-0.5">
<Tooltip>
<TooltipTrigger asChild>
<p
className="text-foreground hover:text-foreground/90 block cursor-default truncate font-mono text-[13px] leading-snug"
tabIndex={0}
>
{primary}
</p>
</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
className="font-mono break-all"
>
{hasExtras ? (
<div className="space-y-1.5">
<p className="text-muted-foreground font-sans text-[10px] tracking-[0.12em] uppercase">
{names.length} release name{names.length === 1 ? "" : "s"}
</p>
<ul className="space-y-1 leading-snug">
{names.map((name, i) => (
<li key={i}>{name}</li>
))}
</ul>
</div>
) : (
<span className="leading-snug">{primary}</span>
)}
</TooltipContent>
</Tooltip>
{hasExtras && expanded && (
<ul className="border-border/60 text-muted-foreground space-y-0.5 border-l pl-2 font-mono text-[12px] leading-snug">
{extras.map((name, i) => (
<li key={i} className="truncate" title={name}>
{name}
</li>
))}
</ul>
)}
{hasExtras && (
<button
type="button"
onClick={() => toggleExpanded(key)}
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-[11px] font-medium transition-colors"
>
<ChevronDown
className={cn(
"size-3 transition-transform",
expanded && "rotate-180",
)}
/>
{expanded
? "Collapse"
: `${extras.length} more variant${extras.length === 1 ? "" : "s"}`}
</button>
)}
</div>
</div>
<div className="flex shrink-0 items-center">
<Button
variant="outline"
size="sm"
onClick={() => handleDownload(result)}
disabled={downloadingKey !== null}
className="min-w-[7.5rem]"
>
{isDownloading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Download className="size-4" />
)}
{isDownloading ? "Downloading" : "Download"}
</Button>
</div>
</div>
);
})}
</div>
) : (
hasSearched &&
!searching &&
!searchError && (
<div className="text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-center text-sm">
No subtitles found for this version and language.
</div>
)
)}
</div>
</TooltipProvider>
</DialogContent>
</Dialog>
);
}