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
+85 -19
View File
@@ -38,7 +38,11 @@ presets, recalled with a click or a `1`-`9` keypress (persisted locally, same pa
Settings/History). The app can also be driven entirely from the command line —
`aurora-curator -rename --format "<naming scheme>" [options] <file-or-folder>...` — for
scripting batch renames without opening the window at all; see "Command-line usage"
below. See "Porting roadmap" below.
below. A new Subtitles panel searches and downloads subtitle files from
[OpenSubtitles](https://www.opensubtitles.com/) — load a video, its title/season/episode
(or movie title/year) auto-fills from the same local matching "Match" uses, pick a
result, and the subtitle is saved right next to the video ready for any player to pick
up automatically; see "Subtitles" below. See "Porting roadmap" below.
The naming scheme is fully user-editable from the app (the "Naming Scheme" button next
to the New Names list) — the default is `{show name} - {season number}x{episode
@@ -144,7 +148,12 @@ crates/aurora-curator-core/ Pure Rust core: naming-expression engine (ported),
`apikey` query-param auth rather than TMDB's Bearer token) — all
three disambiguated via
`similarity/` and tested against a mocked HTTP server via
`mockito`, the on-disk
`mockito`, plus a fourth web client that isn't a metadata
lookup at all — `web/opensubtitles.rs` (search + login +
download against OpenSubtitles' REST API; search only needs an
app-level API key, download additionally needs the end user's own
account login, since downloads are quota-limited per account —
see "Subtitles" below), the on-disk
rename engine (`rename/` — Move/Copy/Symlink actions,
Skip/Auto-number/Override conflict policies, can also organize
into (auto-created) subfolders when
@@ -154,7 +163,7 @@ crates/aurora-curator-core/ Pure Rust core: naming-expression engine (ported),
argument parsing/file expansion (`cli/` — no Tauri or network
dependency at all, see "Command-line usage" below). Not yet
ported: full media metadata bindings, other web datasource
clients (TheTVDB/AniDB/OpenSubtitles/etc). No Tauri dependency, so
clients (TheTVDB/AniDB/etc). No Tauri dependency, so
it builds and unit-tests standalone.
src-tauri/ Tauri app shell: exposes aurora-curator-core as commands to the frontend
(naming-scheme rendering, filename matching for both TV and
@@ -164,8 +173,14 @@ src-tauri/ Tauri app shell: exposes aurora-curator-core as commands
is the stored movie source — independent of the TV source)
— executing a rename
plan, native file/folder dialogs, drag-and-drop expansion), plus
`settings.rs` (TMDB token, OMDb API key, and which TV *and* movie
metadata source Fetch Data uses — two independent settings),
`settings.rs` (TMDB token, OMDb API key, OpenSubtitles API key +
account login, and which TV *and* movie
metadata source Fetch Data uses — independent settings; the
OpenSubtitles password is the one credential this module never
returns to the frontend, only whether one is saved),
`subtitles.rs` (search/download commands wrapping
`aurora-curator-core::web::opensubtitles`, saving a downloaded
subtitle right next to its video file),
`history.rs` (rename history/undo
log), `presets.rs` (up to 9 saved naming schemes) — all three
stored as JSON in the OS app-config directory — `cli.rs` (the
@@ -184,7 +199,10 @@ src/ React + TypeScript UI (Vite). Faithful recreation of the
confirm-before-touching-disk modal for the actual
rename, a History panel that can undo a past rename, Align Up/Down
list reordering, and a Presets panel (plus `1`-`9` hotkeys) for
saved naming schemes.
saved naming schemes. `SubtitlesPanel.tsx` is a separate top-level
panel (its own Subtitle Settings modal, its own credential state)
reached from the sidebar's "Subtitles" entry — the first sidebar
entry besides Rename to be a real screen rather than a stub.
scripts/gen_icons.py Regenerates src-tauri/icons/ from the real logo mark
(`scripts/logo/icon-source.png`, cropped from the supplied
lockup) — replace that source image to change the look.
@@ -209,9 +227,10 @@ npm run tauri dev
npm run tauri build
```
Verified in the environment this was built in: `cargo test --workspace` (all 128
passing, including the TMDB, TVmaze, and OMDb client tests — those run against a local
mock HTTP server via `mockito`, no live network or API key needed to build/test), `cargo
Verified in the environment this was built in: `cargo test --workspace` (all 144
passing, including the TMDB, TVmaze, OMDb, and OpenSubtitles client tests — those run
against a local mock HTTP server via `mockito`, no live network or API key needed to
build/test), `cargo
check -p aurora-curator` (compiles clean against real Tauri + GTK/WebKit system
libraries), and `npm run build` (TypeScript type-checks, Vite bundles). The actual GUI
window has **not** been visually verified anywhere yet — that sandbox has no display.
@@ -296,6 +315,44 @@ tree (see "Organizing into subfolders" above) — a naming scheme's own `/` alre
organizing into a subfolder, and relaxing that invariant just for `--output` wasn't
judged worth it.
## Subtitles
The Subtitles sidebar panel searches and downloads subtitle files from
[OpenSubtitles](https://www.opensubtitles.com/) — the first thing in Aurora Curator that
fetches an actual media file (a subtitle) rather than title/air-date text.
**Load Video File…** picks one video, then reuses the exact same local filename-based
matching "Match" uses (`guess_episode`/`guess_movie`) to pre-fill the search form with a
show title + season/episode, or a movie title + year — all editable before searching,
and usable on their own with no video loaded at all if you'd rather just type a title.
Results show the release name, language, an **HD** badge, a **Hash match** badge when
OpenSubtitles matched the file by an exact content hash rather than a text search (a
much stronger signal than a title match alone), an **HI** badge for hearing-impaired
tracks, the uploader, and the download count. Downloading saves the file right next to
the video with the language code inserted before the extension (`Show.S01E01.mkv` →
`Show.S01E01.en.srt`) — the convention most media players look for automatically, so
there's nothing further to configure.
OpenSubtitles splits credentials in a way no other source in this app does:
- **Searching** only needs a free **API key** — register an account at
[opensubtitles.com](https://www.opensubtitles.com/) and create one under Account → API
Consumers, then paste it into Subtitle Settings.
- **Downloading** additionally needs your own OpenSubtitles account **username and
password**, entered in the same Settings panel — OpenSubtitles logs you in to check a
daily download quota tied to your account (shown after every successful download as
"N/M downloads left today"; the exact number depends on your account's standing with
OpenSubtitles and can change on their end, so Aurora Curator always shows the live
figure it gets back rather than a hardcoded promise). Search works with no login at
all, so you can browse what's available before deciding whether to save a password.
Like every other credential in this app, both are stored locally in the OS app-config
directory and only ever sent to `api.opensubtitles.com` — with one extra precaution
specific to a real account password: Aurora Curator never sends the saved password back
to its own interface once it's saved (every other credential's "already saved" check
does briefly see the real value; the password doesn't), and the Settings panel only ever
shows *whether* a password is on file, never the value itself.
## Porting roadmap (not yet done)
Roughly in priority order — each references the original FileBot source file it's
@@ -354,8 +411,15 @@ ported from, for anyone picking this up:
items resolve against respectively (TMDB stays the default for both; a batch can mix
sources across its TV and movie halves) — disambiguating multi-result searches via
the similarity-scoring pass described in item 3 above rather than blindly trusting
any source's top-ranked hit. All other sources (TheTVDB, AniDB, OpenSubtitles) are not
yet started.
any source's top-ranked hit. **OpenSubtitles is also done, but as a subtitle source
rather than a fourth title-metadata source** — `crates/aurora-curator-core/src/web/opensubtitles.rs`,
wired to the new Subtitles panel (see "Subtitles" above) rather than to Fetch Data's
TV/movie pickers, since it fetches subtitle files, not episode/movie titles. All
other sources (TheTVDB, AniDB) are not yet started — **TheTVDB was deliberately
skipped**: as of this writing its v4 API requires either a paid commercial license or
the "user-supported" model, where every end user needs their own paid TheTVDB
subscription (~$12/year) just to get the PIN its login flow requires, unlike the
three free-to-use sources above — worth revisiting if that changes.
6. **Rename engine + CLI** — `net.filebot.cli.CmdlineOperations` (1235 lines) and
`ArgumentBean` (415 lines, the full flag surface: `-rename`, `--db`, `--format`,
`--action`, `--conflict`, `-get-subtitles`, `-script`, etc.). The rename operation is
@@ -378,16 +442,18 @@ ported from, for anyone picking this up:
`-non-strict`/`-h` (`crates/aurora-curator-core/src/cli` for pure argument parsing and
directory expansion, `src-tauri/src/cli.rs` for the orchestration — local matching,
optional TMDB/TVmaze/OMDb lookup, and the actual rename, all without ever constructing a
Tauri `App`); see "Command-line usage" above. `--output <dir>` and `-get-subtitles`/
`-script`/the rest of FileBot's non-rename CLI modes are not ported — `-get-
subtitles` and `-script` have no analog yet since subtitle-fetching and a script-mode
runtime aren't built at all.
Tauri `App`); see "Command-line usage" above. `--output <dir>` and `-script`/the rest
of FileBot's non-rename CLI modes are not ported. `-get-subtitles` has no CLI analog
yet — subtitle fetching (see "Subtitles" above) is GUI-only so far, reached from its
own sidebar panel rather than a command-line flag.
7. **The rest of the UI** — the Rename panel (file loading, drag-drop, live preview,
local TV/movie matching, confirmed rename with per-item exclude, rename history/undo,
Align Up/Down reordering, naming-scheme Presets with `1`-`9` hotkeys) is built; the
other five sidebar panels (Episodes/Subtitles/SFV/Filter/List) are still stub
screens. Align Up/Down is button-based (swaps the selected file with its neighbor),
not the original's drag-and-drop-within-a-list interaction.
Align Up/Down reordering, naming-scheme Presets with `1`-`9` hotkeys) is built, and so
is the Subtitles panel (search + download via OpenSubtitles, see "Subtitles" above) —
the first of the other five sidebar panels to become a real screen. The remaining
four (Episodes/SFV/Filter/List) are still stub screens. Align Up/Down is button-based
(swaps the selected file with its neighbor), not the original's
drag-and-drop-within-a-list interaction.
8. **Undo/history for renames** — done, as a flat JSON-backed undo stack rather than a
full port of `net.filebot.HistoryDialog`'s session/element log
(`crates/aurora-curator-core/src/history`, `src-tauri/src/history.rs`). Every successful
+8 -5
View File
@@ -1,11 +1,14 @@
//! Online metadata clients ("Fetch Data"). TMDB (TV + movies), TVmaze (TV only, no API
//! key needed), and OMDb (TV + movies, needs a free API key) so far; the original
//! FileBot supports ~40 sources (`net.filebot.web.*`) - TMDB was picked first since
//! it's the most widely useful for both TV and movies, TVmaze second since it's a
//! Online metadata clients ("Fetch Data") plus, as of `opensubtitles`, a first
//! non-metadata web client. TMDB (TV + movies), TVmaze (TV only, no API key needed),
//! and OMDb (TV + movies, needs a free API key) handle title/air-date lookups; the
//! original FileBot supports ~40 sources (`net.filebot.web.*`) - TMDB was picked first
//! since it's the most widely useful for both TV and movies, TVmaze second since it's a
//! zero-setup second TV source (see `tvmaze`'s module doc for why movies aren't in
//! scope for it), OMDb third since it's the first *movie*-capable alternative to TMDB
//! (see `omdb`'s module doc).
//! (see `omdb`'s module doc). `opensubtitles` is a different category entirely -
//! fetching actual subtitle files rather than title metadata - see its own module doc.
pub mod omdb;
pub mod opensubtitles;
pub mod tmdb;
pub mod tvmaze;
@@ -0,0 +1,556 @@
//! A client for [OpenSubtitles](https://www.opensubtitles.com)' REST API
//! (`api.opensubtitles.com/api/v1`) - search for subtitles matching a TV episode or
//! movie, then download the chosen one.
//!
//! Unlike every other client in this module, OpenSubtitles needs **two** different
//! credentials for two different operations:
//! - **Search** only needs an `Api-Key` header (a free "consumer"/app key registered
//! at opensubtitles.com under Account -> API Consumers).
//! - **Download** additionally needs the end user's own OpenSubtitles account to be
//! logged in (`POST /login` with `username`/`password`, returning a short-lived JWT
//! sent as `Authorization: Bearer <token>` on the actual download call) - downloads
//! are quota-limited per account (their docs show this ranging from single digits
//! to 1000/day depending on account rank/VIP status, and the exact number has
//! changed over time), so the download response's `remaining`/`allowed` counts are
//! surfaced to the caller rather than assumed.
//!
//! This is the first source in Aurora Curator that isn't a metadata lookup (title/air
//! date) - it fetches actual subtitle *files* - so its result/error shapes are its own,
//! not mirrored from `tmdb`/`tvmaze`/`omdb`.
use serde::{Deserialize, Serialize};
use thiserror::Error;
const DEFAULT_BASE_URL: &str = "https://api.opensubtitles.com/api/v1";
#[derive(Debug, Error)]
pub enum OpensubtitlesError {
#[error("network error talking to OpenSubtitles: {0}")]
Network(String),
#[error("OpenSubtitles API error (status {status}): {message}")]
Api { status: u16, message: String },
#[error("OpenSubtitles login failed: check the username and password in Fetch Data Settings")]
InvalidCredentials,
}
/// Search parameters. `query` should be a show/movie title; `season`/`episode` narrow a
/// TV search to one episode, `year` narrows either kind to one release year. `language`
/// is a single OpenSubtitles language code (e.g. `"en"`).
#[derive(Debug, Clone)]
pub struct SearchParams {
pub query: String,
pub season: Option<u32>,
pub episode: Option<u32>,
pub year: Option<u32>,
pub language: String,
}
/// One subtitle search result, flattened to the fields Aurora Curator actually shows -
/// OpenSubtitles' real JSON shape wraps all of this in a `{data: [{attributes: {...}}]}`
/// envelope with many more fields than are useful here (see `SearchAttributes` below).
/// `camelCase` on the wire (same convention `matching::EpisodeGuess`/`MovieGuess` use)
/// since this is returned directly by the `search_subtitles` Tauri command with no
/// separate presentation-layer wrapper struct.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SubtitleResult {
/// The id needed to actually download this subtitle - see `OpensubtitlesClient::download`.
pub file_id: u64,
pub release: String,
pub language: String,
pub download_count: i64,
pub hearing_impaired: bool,
pub hd: bool,
/// True when this result was matched via an exact file-hash match rather than a
/// text search - a much stronger correctness signal than a title match alone.
pub moviehash_match: bool,
pub uploader: Option<String>,
}
/// Result of a successful download call: a temporary link to the actual subtitle file,
/// plus the account's live daily quota state so the caller can show it to the user.
#[derive(Debug, Clone, PartialEq)]
pub struct SubtitleDownload {
pub link: String,
pub file_name: String,
pub remaining: i64,
pub allowed: i64,
}
#[derive(Debug, Deserialize)]
struct SearchResponse {
data: Vec<SearchHit>,
}
#[derive(Debug, Deserialize)]
struct SearchHit {
attributes: SearchAttributes,
}
#[derive(Debug, Deserialize)]
struct SearchAttributes {
release: String,
#[serde(default)]
language: Option<String>,
#[serde(default)]
download_count: i64,
#[serde(default)]
hearing_impaired: bool,
#[serde(default)]
hd: bool,
#[serde(default)]
moviehash_match: bool,
#[serde(default)]
uploader: Option<UploaderInfo>,
#[serde(default)]
files: Vec<SubtitleFileRef>,
}
#[derive(Debug, Deserialize)]
struct UploaderInfo {
#[serde(default)]
name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SubtitleFileRef {
file_id: u64,
}
#[derive(Debug, Serialize)]
struct LoginRequest<'a> {
username: &'a str,
password: &'a str,
}
#[derive(Debug, Deserialize)]
struct LoginResponse {
token: String,
}
#[derive(Debug, Serialize)]
struct DownloadRequest {
file_id: u64,
}
#[derive(Debug, Deserialize)]
struct DownloadResponseBody {
link: String,
#[serde(default)]
file_name: String,
#[serde(default)]
remaining: i64,
#[serde(default)]
allowed: i64,
}
pub struct OpensubtitlesClient {
http: reqwest::Client,
base_url: String,
api_key: String,
}
impl OpensubtitlesClient {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
http: reqwest::Client::new(),
base_url: DEFAULT_BASE_URL.to_string(),
api_key: api_key.into(),
}
}
/// Overrides the API base URL - used by tests to point at a local mock server
/// instead of the real OpenSubtitles API.
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
/// Searches for subtitles. Only needs the app-level `Api-Key` - no user login
/// required. Results with no downloadable file attached are skipped rather than
/// surfaced with a missing id.
pub async fn search(&self, params: &SearchParams) -> Result<Vec<SubtitleResult>, OpensubtitlesError> {
let season_str = params.season.map(|s| s.to_string());
let episode_str = params.episode.map(|e| e.to_string());
let year_str = params.year.map(|y| y.to_string());
let mut query: Vec<(&str, &str)> = vec![
("query", params.query.as_str()),
("languages", params.language.as_str()),
];
if let Some(season) = &season_str {
query.push(("season_number", season));
}
if let Some(episode) = &episode_str {
query.push(("episode_number", episode));
}
if let Some(year) = &year_str {
query.push(("year", year));
}
let url = format!("{}/subtitles", self.base_url);
let response = self
.http
.get(&url)
.header("Api-Key", &self.api_key)
.query(&query)
.send()
.await
.map_err(|e| OpensubtitlesError::Network(e.to_string()))?;
if !response.status().is_success() {
let status = response.status().as_u16();
let message = response.text().await.unwrap_or_default();
return Err(OpensubtitlesError::Api { status, message });
}
let body: SearchResponse = response
.json()
.await
.map_err(|e| OpensubtitlesError::Network(e.to_string()))?;
Ok(body
.data
.into_iter()
.filter_map(|hit| {
let file_id = hit.attributes.files.first()?.file_id;
Some(SubtitleResult {
file_id,
release: hit.attributes.release,
language: hit.attributes.language.unwrap_or_default(),
download_count: hit.attributes.download_count,
hearing_impaired: hit.attributes.hearing_impaired,
hd: hit.attributes.hd,
moviehash_match: hit.attributes.moviehash_match,
uploader: hit.attributes.uploader.and_then(|u| u.name),
})
})
.collect())
}
/// Logs in with the end user's own OpenSubtitles account, returning a short-lived
/// JWT to pass to `download`. Required only for downloading, never for searching.
pub async fn login(&self, username: &str, password: &str) -> Result<String, OpensubtitlesError> {
let url = format!("{}/login", self.base_url);
let response = self
.http
.post(&url)
.header("Api-Key", &self.api_key)
.json(&LoginRequest { username, password })
.send()
.await
.map_err(|e| OpensubtitlesError::Network(e.to_string()))?;
let status = response.status();
if status.as_u16() == 401 {
return Err(OpensubtitlesError::InvalidCredentials);
}
if !status.is_success() {
let message = response.text().await.unwrap_or_default();
return Err(OpensubtitlesError::Api {
status: status.as_u16(),
message,
});
}
let body: LoginResponse = response
.json()
.await
.map_err(|e| OpensubtitlesError::Network(e.to_string()))?;
Ok(body.token)
}
/// Exchanges a search result's `file_id` for a temporary download link, given a
/// login token from `login`. The returned `remaining`/`allowed` reflect the
/// account's daily download quota *after* this download.
pub async fn download(&self, token: &str, file_id: u64) -> Result<SubtitleDownload, OpensubtitlesError> {
let url = format!("{}/download", self.base_url);
let response = self
.http
.post(&url)
.header("Api-Key", &self.api_key)
.header("Authorization", format!("Bearer {token}"))
.json(&DownloadRequest { file_id })
.send()
.await
.map_err(|e| OpensubtitlesError::Network(e.to_string()))?;
if !response.status().is_success() {
let status = response.status().as_u16();
let message = response.text().await.unwrap_or_default();
return Err(OpensubtitlesError::Api { status, message });
}
let body: DownloadResponseBody = response
.json()
.await
.map_err(|e| OpensubtitlesError::Network(e.to_string()))?;
Ok(SubtitleDownload {
link: body.link,
file_name: body.file_name,
remaining: body.remaining,
allowed: body.allowed,
})
}
/// Fetches the actual subtitle text from a `download()` result's temporary `link`.
/// Subtitle files (SRT/VTT) are plain text, so this returns a `String` rather than
/// raw bytes.
pub async fn fetch_subtitle_text(&self, link: &str) -> Result<String, OpensubtitlesError> {
let response = self
.http
.get(link)
.send()
.await
.map_err(|e| OpensubtitlesError::Network(e.to_string()))?;
if !response.status().is_success() {
let status = response.status().as_u16();
let message = response.text().await.unwrap_or_default();
return Err(OpensubtitlesError::Api { status, message });
}
response
.text()
.await
.map_err(|e| OpensubtitlesError::Network(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn params(query: &str) -> SearchParams {
SearchParams {
query: query.to_string(),
season: None,
episode: None,
year: None,
language: "en".to_string(),
}
}
#[tokio::test]
async fn searches_and_flattens_results() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/subtitles")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("query".into(), "Girls".into()),
mockito::Matcher::UrlEncoded("languages".into(), "en".into()),
]))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"data":[{"attributes":{"release":"Girls.S02E02.WEB-DL","language":"en",
"download_count":42,"hearing_impaired":false,"hd":true,"moviehash_match":true,
"uploader":{"name":"someone"},"files":[{"file_id":9001}]}}]}"#,
)
.create_async()
.await;
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
let results = client.search(&params("Girls")).await.unwrap();
assert_eq!(
results,
vec![SubtitleResult {
file_id: 9001,
release: "Girls.S02E02.WEB-DL".to_string(),
language: "en".to_string(),
download_count: 42,
hearing_impaired: false,
hd: true,
moviehash_match: true,
uploader: Some("someone".to_string()),
}]
);
}
#[tokio::test]
async fn includes_season_and_episode_in_the_query_only_when_given() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/subtitles")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("season_number".into(), "2".into()),
mockito::Matcher::UrlEncoded("episode_number".into(), "5".into()),
]))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"data":[]}"#)
.create_async()
.await;
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
let mut p = params("Some Show");
p.season = Some(2);
p.episode = Some(5);
let results = client.search(&p).await.unwrap();
assert!(results.is_empty());
}
#[tokio::test]
async fn search_results_with_no_file_attached_are_skipped() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/subtitles")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"data":[
{"attributes":{"release":"No Files","language":"en","download_count":0,
"hearing_impaired":false,"hd":false,"files":[]}},
{"attributes":{"release":"Has A File","language":"en","download_count":1,
"hearing_impaired":false,"hd":false,"files":[{"file_id":5}]}}
]}"#,
)
.create_async()
.await;
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
let results = client.search(&params("anything")).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].release, "Has A File");
}
#[tokio::test]
async fn surfaces_a_server_error_on_search_as_an_api_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/subtitles")
.match_query(mockito::Matcher::Any)
.with_status(503)
.with_header("content-type", "text/plain")
.with_body("service unavailable")
.create_async()
.await;
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
let result = client.search(&params("anything")).await;
match result {
Err(OpensubtitlesError::Api { status, .. }) => assert_eq!(status, 503),
other => panic!("expected Api error, got {other:?}"),
}
}
#[tokio::test]
async fn logs_in_successfully_and_returns_the_token() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/login")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"token":"jwt-abc-123","user":{"allowed_downloads":100}}"#)
.create_async()
.await;
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
let token = client.login("someone", "hunter2").await.unwrap();
assert_eq!(token, "jwt-abc-123");
}
#[tokio::test]
async fn login_with_bad_credentials_reports_invalid_credentials() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/login")
.with_status(401)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"Unauthorized"}"#)
.create_async()
.await;
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
let result = client.login("someone", "wrong-password").await;
assert!(matches!(result, Err(OpensubtitlesError::InvalidCredentials)));
}
#[tokio::test]
async fn downloads_and_returns_the_link_and_quota() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/download")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"link":"https://dl.opensubtitles.com/tmp/abc.srt","file_name":"Girls.S02E02.srt",
"remaining":99,"allowed":100}"#,
)
.create_async()
.await;
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
let download = client.download("jwt-abc-123", 9001).await.unwrap();
assert_eq!(
download,
SubtitleDownload {
link: "https://dl.opensubtitles.com/tmp/abc.srt".to_string(),
file_name: "Girls.S02E02.srt".to_string(),
remaining: 99,
allowed: 100,
}
);
}
#[tokio::test]
async fn download_failure_surfaces_as_an_api_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/download")
.with_status(406)
.with_header("content-type", "application/json")
.with_body(r#"{"message":"Daily download limit reached"}"#)
.create_async()
.await;
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
let result = client.download("jwt-abc-123", 9001).await;
match result {
Err(OpensubtitlesError::Api { status, message }) => {
assert_eq!(status, 406);
assert!(message.contains("limit"));
}
other => panic!("expected Api error, got {other:?}"),
}
}
#[tokio::test]
async fn fetches_the_actual_subtitle_text_from_a_download_link() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/tmp/abc.srt")
.with_status(200)
.with_header("content-type", "text/plain")
.with_body("1\n00:00:01,000 --> 00:00:02,000\nHello\n")
.create_async()
.await;
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
let text = client
.fetch_subtitle_text(&format!("{}/tmp/abc.srt", server.url()))
.await
.unwrap();
assert!(text.contains("Hello"));
}
}
+8
View File
@@ -4,6 +4,7 @@ mod history;
mod metadata;
mod presets;
mod settings;
mod subtitles;
use aurora_curator_core::format::ExpressionFormat;
use aurora_curator_core::matching::{self, EpisodeGuess, MovieGuess, ReleaseInfo};
@@ -107,6 +108,13 @@ pub fn run() {
settings::save_tv_metadata_source,
settings::get_movie_metadata_source,
settings::save_movie_metadata_source,
settings::get_opensubtitles_api_key,
settings::save_opensubtitles_api_key,
settings::get_opensubtitles_username,
settings::has_opensubtitles_password,
settings::save_opensubtitles_login,
subtitles::search_subtitles,
subtitles::download_subtitle,
history::list_rename_history,
history::undo_rename_batch,
presets::list_presets,
+147
View File
@@ -68,6 +68,21 @@ struct StoredSettings {
tv_metadata_source: TvMetadataSource,
#[serde(default)]
movie_metadata_source: MovieMetadataSource,
/// OpenSubtitles "consumer" app key - needed to *search* subtitles at all.
#[serde(default)]
opensubtitles_api_key: Option<String>,
/// The end user's own OpenSubtitles account username - needed, alongside
/// `opensubtitles_password`, only to *download* a subtitle (search works without
/// either). Not treated as sensitive as the password (an OpenSubtitles username is
/// often just a public display name), so it's returned to the frontend as-is like
/// every other credential's get command.
#[serde(default)]
opensubtitles_username: Option<String>,
/// The end user's own OpenSubtitles account password. Unlike every other credential
/// in this file, this is never sent back to the frontend once saved - see
/// [`has_opensubtitles_password`], which only reports whether one is set.
#[serde(default)]
opensubtitles_password: Option<String>,
}
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
@@ -192,6 +207,93 @@ pub fn save_movie_metadata_source(app: AppHandle, source: MovieMetadataSource) -
save(&app, &settings)
}
/// Returns the stored OpenSubtitles app key, or `None` if nothing (or only blank text)
/// is saved. Needed for search; download additionally needs
/// [`opensubtitles_username`]/[`opensubtitles_password_is_set`].
pub fn opensubtitles_api_key(app: &AppHandle) -> Option<String> {
load(app).opensubtitles_api_key.filter(|t| !t.trim().is_empty())
}
/// Returns the stored OpenSubtitles account username, or `None` if unset.
pub fn opensubtitles_username(app: &AppHandle) -> Option<String> {
load(app).opensubtitles_username.filter(|t| !t.trim().is_empty())
}
/// Returns the stored OpenSubtitles account password, or `None` if unset. Kept
/// `pub(crate)` rather than `pub` - unlike every other credential, this value must
/// never cross the Tauri command boundary back to the frontend (see
/// [`has_opensubtitles_password`]), so only this crate's own subtitle-download code
/// should ever read it.
pub(crate) fn opensubtitles_password(app: &AppHandle) -> Option<String> {
load(app).opensubtitles_password.filter(|t| !t.trim().is_empty())
}
#[tauri::command]
pub fn get_opensubtitles_api_key(app: AppHandle) -> Option<String> {
opensubtitles_api_key(&app)
}
#[tauri::command]
pub fn save_opensubtitles_api_key(app: AppHandle, key: String) -> Result<(), String> {
let mut settings = load(&app);
let trimmed = key.trim().to_string();
settings.opensubtitles_api_key = if trimmed.is_empty() { None } else { Some(trimmed) };
save(&app, &settings)
}
#[tauri::command]
pub fn get_opensubtitles_username(app: AppHandle) -> Option<String> {
opensubtitles_username(&app)
}
/// Reports only *whether* a password is saved, never the password itself - the one
/// deliberate exception to this module's usual "get returns the real saved value"
/// pattern, since a real account password is more sensitive than an API token/key.
#[tauri::command]
pub fn has_opensubtitles_password(app: AppHandle) -> bool {
opensubtitles_password(&app).is_some()
}
/// Pure merge logic for [`save_opensubtitles_login`], split out so it's directly unit
/// testable without an `AppHandle`. A blank field keeps whatever was saved for it
/// before (so updating just the password doesn't force retyping the username, and vice
/// versa) - only when BOTH are blank does this clear the login entirely.
fn merge_login_update(
current_username: Option<String>,
current_password: Option<String>,
username: String,
password: String,
) -> (Option<String>, Option<String>) {
if username.is_empty() && password.is_empty() {
return (None, None);
}
let new_username = {
let trimmed = username.trim().to_string();
if trimmed.is_empty() { current_username } else { Some(trimmed) }
};
let new_password = {
let trimmed = password.trim().to_string();
if trimmed.is_empty() { current_password } else { Some(trimmed) }
};
(new_username, new_password)
}
/// Saves the OpenSubtitles login (username + password) as one unit, since downloading
/// needs both together. See [`merge_login_update`] for the exact keep-vs-clear rules.
#[tauri::command]
pub fn save_opensubtitles_login(app: AppHandle, username: String, password: String) -> Result<(), String> {
let mut settings = load(&app);
let (new_username, new_password) = merge_login_update(
settings.opensubtitles_username.take(),
settings.opensubtitles_password.take(),
username,
password,
);
settings.opensubtitles_username = new_username;
settings.opensubtitles_password = new_password;
save(&app, &settings)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -258,4 +360,49 @@ mod tests {
assert_eq!(parsed.movie_metadata_source, MovieMetadataSource::Tmdb);
assert_eq!(parsed.tv_metadata_source, TvMetadataSource::Tvmaze);
}
#[test]
fn stored_settings_without_opensubtitles_fields_still_deserializes() {
// Same schema-evolution guarantee as the OMDb test above, for the three fields
// the OpenSubtitles subtitle feature added.
let old_json = r#"{"tmdb_token":"abc123","omdb_api_key":"def456"}"#;
let parsed: StoredSettings = serde_json::from_str(old_json).unwrap();
assert_eq!(parsed.opensubtitles_api_key, None);
assert_eq!(parsed.opensubtitles_username, None);
assert_eq!(parsed.opensubtitles_password, None);
}
#[test]
fn merge_login_update_keeps_the_other_field_when_one_is_blank() {
// Updating just the password shouldn't silently clear a previously-saved
// username, and vice versa.
let (username, password) = merge_login_update(
Some("old-name".to_string()),
Some("old-pass".to_string()),
String::new(),
"new-pass".to_string(),
);
assert_eq!(username, Some("old-name".to_string()));
assert_eq!(password, Some("new-pass".to_string()));
}
#[test]
fn merge_login_update_clears_both_only_when_both_are_blank() {
let (username, password) = merge_login_update(
Some("old-name".to_string()),
Some("old-pass".to_string()),
String::new(),
String::new(),
);
assert_eq!(username, None);
assert_eq!(password, None);
}
#[test]
fn merge_login_update_trims_whitespace() {
let (username, password) =
merge_login_update(None, None, " someone ".to_string(), " hunter2 ".to_string());
assert_eq!(username, Some("someone".to_string()));
assert_eq!(password, Some("hunter2".to_string()));
}
}
+138
View File
@@ -0,0 +1,138 @@
//! The Tauri-facing side of subtitle search/download, via OpenSubtitles
//! (`aurora_curator_core::web::opensubtitles`) - the app's first web feature that isn't
//! a title/air-date metadata lookup. Search only needs the OpenSubtitles app key saved
//! in Fetch Data Settings; download additionally needs the user's own OpenSubtitles
//! account login (also saved there, kept separately from the app key - see
//! `settings.rs`'s `opensubtitles_*` functions) since downloads are quota-limited per
//! account, not per app.
use aurora_curator_core::web::opensubtitles::{OpensubtitlesClient, OpensubtitlesError, SearchParams, SubtitleResult};
use serde::Serialize;
use std::path::Path;
use tauri::AppHandle;
use crate::settings;
fn describe_error(err: OpensubtitlesError) -> String {
match err {
OpensubtitlesError::InvalidCredentials => {
"OpenSubtitles login failed - check the username and password in Fetch Data Settings.".to_string()
}
other => other.to_string(),
}
}
/// Searches OpenSubtitles for subtitles matching a TV episode or movie. Only needs the
/// app-level API key - no account login required (unlike `download_subtitle`).
#[tauri::command]
pub async fn search_subtitles(
app: AppHandle,
query: String,
season: Option<u32>,
episode: Option<u32>,
year: Option<u32>,
language: String,
) -> Result<Vec<SubtitleResult>, String> {
let api_key = settings::opensubtitles_api_key(&app)
.ok_or_else(|| "Add a free OpenSubtitles API key in Fetch Data Settings first.".to_string())?;
let client = OpensubtitlesClient::new(api_key);
let language = if language.trim().is_empty() {
"en".to_string()
} else {
language
};
let params = SearchParams {
query,
season,
episode,
year,
language,
};
client.search(&params).await.map_err(describe_error)
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SubtitleDownloadOutcome {
pub saved_path: String,
pub remaining_downloads: i64,
pub allowed_downloads: i64,
}
/// Logs in with the user's saved OpenSubtitles account, downloads one subtitle by its
/// search-result `file_id`, and writes it to disk right next to `video_path`. Requires
/// all three OpenSubtitles credentials (API key + username + password) to already be
/// saved through Fetch Data Settings - Aurora Curator never accepts them as arguments
/// from anywhere but that settings form, same standing rule as every other credential.
#[tauri::command]
pub async fn download_subtitle(
app: AppHandle,
file_id: u64,
video_path: String,
language: String,
) -> Result<SubtitleDownloadOutcome, String> {
let api_key = settings::opensubtitles_api_key(&app)
.ok_or_else(|| "Add a free OpenSubtitles API key in Fetch Data Settings first.".to_string())?;
let username = settings::opensubtitles_username(&app).ok_or_else(|| {
"Add your OpenSubtitles account login in Fetch Data Settings first - downloading needs it, even though searching doesn't.".to_string()
})?;
let password = settings::opensubtitles_password(&app).ok_or_else(|| {
"Add your OpenSubtitles account login in Fetch Data Settings first - downloading needs it, even though searching doesn't.".to_string()
})?;
let client = OpensubtitlesClient::new(api_key);
let token = client.login(&username, &password).await.map_err(describe_error)?;
let download = client.download(&token, file_id).await.map_err(describe_error)?;
let text = client.fetch_subtitle_text(&download.link).await.map_err(describe_error)?;
let saved_path = subtitle_path_for(&video_path, &language)?;
std::fs::write(&saved_path, text).map_err(|e| format!("couldn't write {saved_path}: {e}"))?;
Ok(SubtitleDownloadOutcome {
saved_path,
remaining_downloads: download.remaining,
allowed_downloads: download.allowed,
})
}
/// Computes where a downloaded subtitle should be saved: right next to the video file,
/// same base name, with the language code inserted before a `.srt` extension (e.g.
/// `Show.S01E01.mkv` -> `Show.S01E01.en.srt`) - the convention most media players (and
/// FileBot itself) look for automatically, so no separate "subtitles" folder or naming
/// scheme is needed for this to just work.
fn subtitle_path_for(video_path: &str, language: &str) -> Result<String, String> {
let path = Path::new(video_path);
let parent = path
.parent()
.ok_or_else(|| format!("{video_path} has no parent directory"))?;
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| format!("{video_path} has no filename"))?;
let lang = if language.trim().is_empty() { "en" } else { language.trim() };
Ok(parent.join(format!("{stem}.{lang}.srt")).to_string_lossy().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subtitle_path_sits_next_to_the_video_with_the_language_code_inserted() {
let path = subtitle_path_for("/movies/Inception (2010)/Inception.2010.1080p.mkv", "en").unwrap();
assert_eq!(path, "/movies/Inception (2010)/Inception.2010.1080p.en.srt");
}
#[test]
fn subtitle_path_defaults_to_english_when_no_language_is_given() {
let path = subtitle_path_for("/tv/Show/Show.S01E01.mkv", "").unwrap();
assert_eq!(path, "/tv/Show/Show.S01E01.en.srt");
}
#[test]
fn subtitle_path_trims_whitespace_from_the_language_code() {
let path = subtitle_path_for("/tv/Show/Show.S01E01.mkv", " fr ").unwrap();
assert_eq!(path, "/tv/Show/Show.S01E01.fr.srt");
}
}
+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(