From b47d28375b9902f7e5ebb02b8370c424288e0ec7 Mon Sep 17 00:00:00 2001 From: Renamr Scaffold Date: Sat, 29 Aug 2026 14:08:17 +0000 Subject: [PATCH] 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). --- README.md | 104 +++- crates/aurora-curator-core/src/web/mod.rs | 13 +- .../src/web/opensubtitles.rs | 556 ++++++++++++++++++ src-tauri/src/lib.rs | 8 + src-tauri/src/settings.rs | 147 +++++ src-tauri/src/subtitles.rs | 138 +++++ src/App.tsx | 9 +- src/components/SubtitlesPanel.css | 144 +++++ src/components/SubtitlesPanel.tsx | 455 ++++++++++++++ src/main.tsx | 1 + 10 files changed, 1550 insertions(+), 25 deletions(-) create mode 100644 crates/aurora-curator-core/src/web/opensubtitles.rs create mode 100644 src-tauri/src/subtitles.rs create mode 100644 src/components/SubtitlesPanel.css create mode 100644 src/components/SubtitlesPanel.tsx diff --git a/README.md b/README.md index 8345659..a0d86af 100644 --- a/README.md +++ b/README.md @@ -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 "" [options] ...` — 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 ` 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 ` 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 diff --git a/crates/aurora-curator-core/src/web/mod.rs b/crates/aurora-curator-core/src/web/mod.rs index c1b1fc6..b4cbe5c 100644 --- a/crates/aurora-curator-core/src/web/mod.rs +++ b/crates/aurora-curator-core/src/web/mod.rs @@ -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; diff --git a/crates/aurora-curator-core/src/web/opensubtitles.rs b/crates/aurora-curator-core/src/web/opensubtitles.rs new file mode 100644 index 0000000..74c629b --- /dev/null +++ b/crates/aurora-curator-core/src/web/opensubtitles.rs @@ -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 ` 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, + pub episode: Option, + pub year: Option, + 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, +} + +/// 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, +} + +#[derive(Debug, Deserialize)] +struct SearchHit { + attributes: SearchAttributes, +} + +#[derive(Debug, Deserialize)] +struct SearchAttributes { + release: String, + #[serde(default)] + language: Option, + #[serde(default)] + download_count: i64, + #[serde(default)] + hearing_impaired: bool, + #[serde(default)] + hd: bool, + #[serde(default)] + moviehash_match: bool, + #[serde(default)] + uploader: Option, + #[serde(default)] + files: Vec, +} + +#[derive(Debug, Deserialize)] +struct UploaderInfo { + #[serde(default)] + name: Option, +} + +#[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) -> 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) -> 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, 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 { + 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 { + 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 { + 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(¶ms("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(¶ms("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(¶ms("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")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 76d1c7a..b36dfc1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index ae203bc..28ad9b6 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -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, + /// 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, + /// 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, } fn settings_path(app: &AppHandle) -> Result { @@ -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 { + 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 { + 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 { + load(app).opensubtitles_password.filter(|t| !t.trim().is_empty()) +} + +#[tauri::command] +pub fn get_opensubtitles_api_key(app: AppHandle) -> Option { + 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 { + 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, + current_password: Option, + username: String, + password: String, +) -> (Option, Option) { + 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())); + } } diff --git a/src-tauri/src/subtitles.rs b/src-tauri/src/subtitles.rs new file mode 100644 index 0000000..0cbbfb7 --- /dev/null +++ b/src-tauri/src/subtitles.rs @@ -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, + episode: Option, + year: Option, + language: String, +) -> Result, 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(¶ms).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 { + 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 { + 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"); + } +} diff --git a/src/App.tsx b/src/App.tsx index 4fa80fe..3d733bb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() {
- {selected === "rename" ? : } + {selected === "rename" ? ( + + ) : selected === "subtitles" ? ( + + ) : ( + + )}
diff --git a/src/components/SubtitlesPanel.css b/src/components/SubtitlesPanel.css new file mode 100644 index 0000000..9eccb3c --- /dev/null +++ b/src/components/SubtitlesPanel.css @@ -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); +} diff --git a/src/components/SubtitlesPanel.tsx b/src/components/SubtitlesPanel.tsx new file mode 100644 index 0000000..65a1bc5 --- /dev/null +++ b/src/components/SubtitlesPanel.tsx @@ -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(null); + const [videoName, setVideoName] = useState(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([]); + const [searching, setSearching] = useState(false); + const [searched, setSearched] = useState(false); + const [downloadingId, setDownloadingId] = useState(null); + const [status, setStatus] = useState(null); + + const [settingsOpen, setSettingsOpen] = useState(false); + const [savingSettings, setSavingSettings] = useState(false); + const [hasApiKey, setHasApiKey] = useState(null); + const [hasUsername, setHasUsername] = useState(null); + const [hasPassword, setHasPassword] = useState(null); + const [apiKeyInput, setApiKeyInput] = useState(""); + const [usernameInput, setUsernameInput] = useState(""); + const [passwordInput, setPasswordInput] = useState(""); + + useEffect(() => { + invoke("get_opensubtitles_api_key") + .then((key) => setHasApiKey(!!key)) + .catch(() => setHasApiKey(false)); + invoke("get_opensubtitles_username") + .then((name) => setHasUsername(!!name)) + .catch(() => setHasUsername(false)); + invoke("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("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("guess_episode", { filename }).catch(() => null); + if (episodeGuess) { + setQuery(episodeGuess.seriesName); + setSeason(String(episodeGuess.season)); + setEpisode(String(episodeGuess.episode)); + setYear(""); + return; + } + + const movieGuess = await invoke("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("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("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 ( +
+
+ + {videoName ?? "No video file loaded"} + +
+ +
+
+ + setQuery(e.target.value)} + placeholder="Show or movie title" + /> +
+
+
+ + setSeason(e.target.value)} + placeholder="TV only" + /> +
+
+ + setEpisode(e.target.value)} + placeholder="TV only" + /> +
+
+ + setYear(e.target.value)} + placeholder="Optional" + /> +
+
+ + +
+
+ +
+ +
+ {results.map((r) => ( +
+
+ {r.release} + + {r.language} + {r.hd && HD} + {r.moviehashMatch && Hash match} + {r.hearingImpaired && HI} + + + {r.uploader ? `by ${r.uploader} — ` : ""} + {r.downloadCount.toLocaleString()} downloads + +
+ +
+ ))} + {searched && results.length === 0 &&

No subtitles found.

} + {!searched && ( +

+ 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. +

+ )} +
+ + {status &&
{status}
} + + {settingsOpen && ( +
!savingSettings && setSettingsOpen(false)}> +
e.stopPropagation()}> +

Subtitle Settings

+

+ Get a free API key by creating an account at{" "} + + OpenSubtitles + {" "} + 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). +

+ + setApiKeyInput(e.target.value)} + placeholder={hasApiKey ? "•••••••••• (already saved — enter a new key to replace it)" : "Paste your OpenSubtitles API key here"} + /> + {hasApiKey && ( + + )} + + + setUsernameInput(e.target.value)} + placeholder={hasUsername ? "already saved — enter a new username to replace it" : "Your OpenSubtitles username"} + /> + + setPasswordInput(e.target.value)} + placeholder={hasPassword ? "•••••••••• (already saved — enter a new password to replace it)" : "Your OpenSubtitles password"} + /> + {(hasUsername || hasPassword) && ( + + )} + +
+ + +
+
+
+ )} +
+ ); +} diff --git a/src/main.tsx b/src/main.tsx index 6688f9f..a64a5d5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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(