Add OpenSubtitles subtitle search + download (new Subtitles panel)

New web client (crates/aurora-curator-core/src/web/opensubtitles.rs, 9 tests):
search, login, download, and fetch-subtitle-text against OpenSubtitles' REST
API. Search only needs a free app-level API key; download additionally needs
the end user's own OpenSubtitles account login (username/password -> JWT),
since downloads are quota-limited per account. Settings gained three new
fields (opensubtitles_api_key/username/password) following the existing
TMDB/OMDb credential pattern, with one deliberate exception: the password is
never returned to the frontend, only whether one is saved
(has_opensubtitles_password), since it's a real account password rather than
an API token.

New src-tauri/src/subtitles.rs wraps search/download as Tauri commands and
writes a downloaded subtitle right next to its video file
(Show.S01E01.mkv -> Show.S01E01.en.srt).

New src/components/SubtitlesPanel.tsx is the first real screen behind a
sidebar entry besides Rename (previously a stub) - load a video, auto-fill
the search form from the same local guess_episode/guess_movie Match uses,
search, and download with live remaining/allowed quota shown per result.

TheTVDB was considered instead but deliberately skipped for now: its v4 API
requires either a paid commercial license or a per-end-user paid TheTVDB
subscription just to log in, unlike every other source in this app.

144 tests passing (workspace). Updated README (status paragraph, project
layout, new "Subtitles" section, roadmap items 5/6/7).
This commit is contained in:
Renamr Scaffold
2026-08-29 14:08:17 +00:00
parent c158befa83
commit b47d28375b
10 changed files with 1550 additions and 25 deletions
+8 -1
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import Sidebar, { PANELS } from "./components/Sidebar";
import Header from "./components/Header";
import RenamePanel from "./components/RenamePanel";
import SubtitlesPanel from "./components/SubtitlesPanel";
import PanelStub from "./components/PanelStub";
export default function App() {
@@ -14,7 +15,13 @@ export default function App() {
<div className="main-column">
<Header title={current.name} />
<div className="main-content">
{selected === "rename" ? <RenamePanel /> : <PanelStub name={current.name} />}
{selected === "rename" ? (
<RenamePanel />
) : selected === "subtitles" ? (
<SubtitlesPanel />
) : (
<PanelStub name={current.name} />
)}
</div>
</div>
</div>
+144
View File
@@ -0,0 +1,144 @@
.subtitles-panel {
height: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px;
overflow-y: auto;
}
.subtitles-toolbar {
display: flex;
align-items: center;
gap: 12px;
}
.subtitles-loaded-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted-text);
font-size: 12.5px;
}
.subtitles-settings-button {
margin-left: auto;
}
.subtitles-search-form {
display: flex;
flex-direction: column;
gap: 8px;
background: var(--card);
border: 1px solid var(--surface-deep);
border-radius: 12px;
padding: 14px;
}
.subtitles-search-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.subtitles-search-row-inline {
flex-direction: row;
gap: 12px;
align-items: flex-end;
}
.subtitles-search-row-inline > div {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.subtitles-search-row-inline > div:first-child,
.subtitles-search-row-inline > div:nth-child(2) {
flex: 0 0 88px;
}
.subtitles-search-row-inline > div:nth-child(3) {
flex: 0 0 96px;
}
.subtitles-search-row-inline > div:last-child {
flex: 1;
min-width: 140px;
}
.subtitles-results {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.subtitles-result-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--card);
border: 1px solid var(--surface-deep);
border-radius: 10px;
padding: 10px 12px;
}
.subtitles-result-main {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.subtitles-result-release {
font-size: 12.5px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtitles-result-badges {
display: flex;
gap: 6px;
}
.subtitles-badge {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--muted-text);
background: var(--surface-alt);
border: 1px solid var(--surface-deep);
border-radius: 6px;
padding: 2px 6px;
}
.subtitles-badge-strong {
color: var(--aurora-green);
border-color: var(--aurora-green);
}
.subtitles-result-meta {
font-size: 11.5px;
color: var(--muted-text);
}
.subtitles-empty-note {
color: var(--muted-text);
font-size: 12.5px;
padding: 8px 2px;
}
.subtitles-status {
font-size: 12px;
color: var(--aurora-cyan);
}
+455
View File
@@ -0,0 +1,455 @@
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
interface FileEntry {
path: string;
name: string;
ext: string;
}
interface EpisodeGuess {
seriesName: string;
season: number;
episode: number;
}
interface MovieGuess {
title: string;
year: number | null;
}
interface SubtitleResult {
fileId: number;
release: string;
language: string;
downloadCount: number;
hearingImpaired: boolean;
hd: boolean;
moviehashMatch: boolean;
uploader: string | null;
}
interface SubtitleDownloadOutcome {
savedPath: string;
remainingDownloads: number;
allowedDownloads: number;
}
const LANGUAGE_OPTIONS: { code: string; label: string }[] = [
{ code: "en", label: "English" },
{ code: "es", label: "Spanish" },
{ code: "fr", label: "French" },
{ code: "de", label: "German" },
{ code: "it", label: "Italian" },
{ code: "pt", label: "Portuguese" },
{ code: "pt-BR", label: "Portuguese (Brazil)" },
{ code: "nl", label: "Dutch" },
{ code: "sv", label: "Swedish" },
{ code: "pl", label: "Polish" },
{ code: "ru", label: "Russian" },
{ code: "ja", label: "Japanese" },
{ code: "ko", label: "Korean" },
{ code: "zh-CN", label: "Chinese (Simplified)" },
{ code: "ar", label: "Arabic" },
];
export default function SubtitlesPanel() {
const [videoPath, setVideoPath] = useState<string | null>(null);
const [videoName, setVideoName] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [season, setSeason] = useState("");
const [episode, setEpisode] = useState("");
const [year, setYear] = useState("");
const [language, setLanguage] = useState("en");
const [results, setResults] = useState<SubtitleResult[]>([]);
const [searching, setSearching] = useState(false);
const [searched, setSearched] = useState(false);
const [downloadingId, setDownloadingId] = useState<number | null>(null);
const [status, setStatus] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [savingSettings, setSavingSettings] = useState(false);
const [hasApiKey, setHasApiKey] = useState<boolean | null>(null);
const [hasUsername, setHasUsername] = useState<boolean | null>(null);
const [hasPassword, setHasPassword] = useState<boolean | null>(null);
const [apiKeyInput, setApiKeyInput] = useState("");
const [usernameInput, setUsernameInput] = useState("");
const [passwordInput, setPasswordInput] = useState("");
useEffect(() => {
invoke<string | null>("get_opensubtitles_api_key")
.then((key) => setHasApiKey(!!key))
.catch(() => setHasApiKey(false));
invoke<string | null>("get_opensubtitles_username")
.then((name) => setHasUsername(!!name))
.catch(() => setHasUsername(false));
invoke<boolean>("has_opensubtitles_password")
.then(setHasPassword)
.catch(() => setHasPassword(false));
}, []);
const showStatus = (message: string) => {
setStatus(message);
window.setTimeout(() => setStatus((current) => (current === message ? null : current)), 5000);
};
const loadVideo = async () => {
try {
const entries = await invoke<FileEntry[]>("pick_files");
const entry = entries?.[0];
if (!entry) return;
setVideoPath(entry.path);
setVideoName(entry.ext ? `${entry.name}.${entry.ext}` : entry.name);
setResults([]);
setSearched(false);
const filename = entry.ext ? `${entry.name}.${entry.ext}` : entry.name;
const episodeGuess = await invoke<EpisodeGuess | null>("guess_episode", { filename }).catch(() => null);
if (episodeGuess) {
setQuery(episodeGuess.seriesName);
setSeason(String(episodeGuess.season));
setEpisode(String(episodeGuess.episode));
setYear("");
return;
}
const movieGuess = await invoke<MovieGuess | null>("guess_movie", { filename }).catch(() => null);
if (movieGuess) {
setQuery(movieGuess.title);
setSeason("");
setEpisode("");
setYear(movieGuess.year ? String(movieGuess.year) : "");
} else {
setQuery(entry.name);
setSeason("");
setEpisode("");
setYear("");
}
} catch (err) {
showStatus(`Couldn't load a file: ${err}`);
}
};
const runSearch = async () => {
const trimmedQuery = query.trim();
if (!trimmedQuery) {
showStatus("Enter a title to search for first.");
return;
}
setSearching(true);
setSearched(false);
try {
const found = await invoke<SubtitleResult[]>("search_subtitles", {
query: trimmedQuery,
season: season.trim() ? Number(season.trim()) : null,
episode: episode.trim() ? Number(episode.trim()) : null,
year: year.trim() ? Number(year.trim()) : null,
language,
});
setResults(found);
setSearched(true);
if (found.length === 0) {
showStatus("No subtitles found for that search.");
}
} catch (err) {
setResults([]);
setSearched(true);
const message = String(err);
showStatus(message);
if (message.toLowerCase().includes("settings")) {
setSettingsOpen(true);
}
} finally {
setSearching(false);
}
};
const downloadResult = async (result: SubtitleResult) => {
if (!videoPath) {
showStatus("Load a video file first — a subtitle needs somewhere to be saved next to.");
return;
}
setDownloadingId(result.fileId);
try {
const outcome = await invoke<SubtitleDownloadOutcome>("download_subtitle", {
fileId: result.fileId,
videoPath,
language: result.language || language,
});
showStatus(
`Saved ${outcome.savedPath.split("/").pop()} (${outcome.remainingDownloads}/${outcome.allowedDownloads} OpenSubtitles downloads left today).`
);
} catch (err) {
const message = String(err);
showStatus(message);
if (message.toLowerCase().includes("settings")) {
setSettingsOpen(true);
}
} finally {
setDownloadingId(null);
}
};
const openSettings = () => {
setApiKeyInput("");
setUsernameInput("");
setPasswordInput("");
setSettingsOpen(true);
};
const saveSettings = async () => {
const trimmedKey = apiKeyInput.trim();
const trimmedUsername = usernameInput.trim();
const trimmedPassword = passwordInput.trim();
if (!trimmedKey && !trimmedUsername && !trimmedPassword) {
setSettingsOpen(false);
return;
}
setSavingSettings(true);
try {
if (trimmedKey) {
await invoke("save_opensubtitles_api_key", { key: trimmedKey });
setHasApiKey(true);
}
if (trimmedUsername || trimmedPassword) {
await invoke("save_opensubtitles_login", { username: usernameInput, password: passwordInput });
if (trimmedUsername) setHasUsername(true);
if (trimmedPassword) setHasPassword(true);
}
setApiKeyInput("");
setUsernameInput("");
setPasswordInput("");
setSettingsOpen(false);
showStatus("Subtitle settings saved.");
} catch (err) {
showStatus(`Couldn't save: ${err}`);
} finally {
setSavingSettings(false);
}
};
const clearApiKey = async () => {
setSavingSettings(true);
try {
await invoke("save_opensubtitles_api_key", { key: "" });
setHasApiKey(false);
showStatus("OpenSubtitles API key removed.");
} finally {
setSavingSettings(false);
}
};
const clearLogin = async () => {
setSavingSettings(true);
try {
await invoke("save_opensubtitles_login", { username: "", password: "" });
setHasUsername(false);
setHasPassword(false);
showStatus("OpenSubtitles login removed.");
} finally {
setSavingSettings(false);
}
};
return (
<div className="subtitles-panel">
<div className="subtitles-toolbar">
<button className="action-button" onClick={loadVideo}>
Load Video File…
</button>
<span className="subtitles-loaded-name">{videoName ?? "No video file loaded"}</span>
<button className="action-button subtitles-settings-button" onClick={openSettings}>
Subtitle Settings
</button>
</div>
<div className="subtitles-search-form">
<div className="subtitles-search-row">
<label className="modal-field-label" htmlFor="subtitles-query">
Title
</label>
<input
id="subtitles-query"
className="modal-text-input"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Show or movie title"
/>
</div>
<div className="subtitles-search-row subtitles-search-row-inline">
<div>
<label className="modal-field-label" htmlFor="subtitles-season">
Season
</label>
<input
id="subtitles-season"
className="modal-text-input"
value={season}
onChange={(e) => setSeason(e.target.value)}
placeholder="TV only"
/>
</div>
<div>
<label className="modal-field-label" htmlFor="subtitles-episode">
Episode
</label>
<input
id="subtitles-episode"
className="modal-text-input"
value={episode}
onChange={(e) => setEpisode(e.target.value)}
placeholder="TV only"
/>
</div>
<div>
<label className="modal-field-label" htmlFor="subtitles-year">
Year
</label>
<input
id="subtitles-year"
className="modal-text-input"
value={year}
onChange={(e) => setYear(e.target.value)}
placeholder="Optional"
/>
</div>
<div>
<label className="modal-field-label" htmlFor="subtitles-language">
Language
</label>
<select
id="subtitles-language"
className="modal-text-input"
value={language}
onChange={(e) => setLanguage(e.target.value)}
>
{LANGUAGE_OPTIONS.map((l) => (
<option key={l.code} value={l.code}>
{l.label}
</option>
))}
</select>
</div>
</div>
<button className="action-button primary" onClick={runSearch} disabled={searching}>
{searching ? "Searching…" : "Search Subtitles"}
</button>
</div>
<div className="subtitles-results">
{results.map((r) => (
<div className="subtitles-result-row" key={r.fileId}>
<div className="subtitles-result-main">
<span className="subtitles-result-release">{r.release}</span>
<span className="subtitles-result-badges">
<span className="subtitles-badge">{r.language}</span>
{r.hd && <span className="subtitles-badge">HD</span>}
{r.moviehashMatch && <span className="subtitles-badge subtitles-badge-strong">Hash match</span>}
{r.hearingImpaired && <span className="subtitles-badge">HI</span>}
</span>
<span className="subtitles-result-meta">
{r.uploader ? `by ${r.uploader} — ` : ""}
{r.downloadCount.toLocaleString()} downloads
</span>
</div>
<button
className="action-button"
onClick={() => downloadResult(r)}
disabled={downloadingId === r.fileId}
>
{downloadingId === r.fileId ? "Downloading…" : "Download"}
</button>
</div>
))}
{searched && results.length === 0 && <p className="subtitles-empty-note">No subtitles found.</p>}
{!searched && (
<p className="subtitles-empty-note">
Load a video file (or just type a title) and search — OpenSubtitles searching needs a free API key,
downloading also needs your OpenSubtitles account login. See Subtitle Settings above.
</p>
)}
</div>
{status && <div className="subtitles-status">{status}</div>}
{settingsOpen && (
<div className="modal-overlay" onClick={() => !savingSettings && setSettingsOpen(false)}>
<div className="modal-card modal-card-compact" onClick={(e) => e.stopPropagation()}>
<h2 className="modal-title">Subtitle Settings</h2>
<p className="modal-subtitle">
Get a free API key by creating an account at{" "}
<a href="https://www.opensubtitles.com/" target="_blank" rel="noreferrer">
OpenSubtitles
</a>{" "}
and registering an app under Account → API Consumers — needed just to search. Downloading a subtitle
additionally needs your own OpenSubtitles account login below, and is limited to a daily quota that
OpenSubtitles sets per account (shown after each download).
</p>
<label htmlFor="opensubtitles-key" className="modal-field-label">
OpenSubtitles API Key
</label>
<input
id="opensubtitles-key"
className="modal-text-input"
type="password"
autoComplete="off"
spellCheck={false}
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
placeholder={hasApiKey ? "•••••••••• (already saved — enter a new key to replace it)" : "Paste your OpenSubtitles API key here"}
/>
{hasApiKey && (
<button className="action-button modal-inline-remove" onClick={clearApiKey} disabled={savingSettings}>
Remove API Key
</button>
)}
<label htmlFor="opensubtitles-username" className="modal-field-label">
OpenSubtitles Username
</label>
<input
id="opensubtitles-username"
className="modal-text-input"
type="text"
autoComplete="off"
spellCheck={false}
value={usernameInput}
onChange={(e) => setUsernameInput(e.target.value)}
placeholder={hasUsername ? "already saved — enter a new username to replace it" : "Your OpenSubtitles username"}
/>
<label htmlFor="opensubtitles-password" className="modal-field-label">
OpenSubtitles Password
</label>
<input
id="opensubtitles-password"
className="modal-text-input"
type="password"
autoComplete="off"
spellCheck={false}
value={passwordInput}
onChange={(e) => setPasswordInput(e.target.value)}
placeholder={hasPassword ? "•••••••••• (already saved — enter a new password to replace it)" : "Your OpenSubtitles password"}
/>
{(hasUsername || hasPassword) && (
<button className="action-button modal-inline-remove" onClick={clearLogin} disabled={savingSettings}>
Remove Login
</button>
)}
<div className="modal-actions">
<button className="action-button" onClick={() => setSettingsOpen(false)} disabled={savingSettings}>
Cancel
</button>
<button className="action-button primary" onClick={saveSettings} disabled={savingSettings}>
{savingSettings ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+1
View File
@@ -6,6 +6,7 @@ import "./App.css";
import "./components/Sidebar.css";
import "./components/Header.css";
import "./components/RenamePanel.css";
import "./components/SubtitlesPanel.css";
import "./components/PanelStub.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(