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:
@@ -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 —
|
Settings/History). The app can also be driven entirely from the command line —
|
||||||
`aurora-curator -rename --format "<naming scheme>" [options] <file-or-folder>...` — for
|
`aurora-curator -rename --format "<naming scheme>" [options] <file-or-folder>...` — for
|
||||||
scripting batch renames without opening the window at all; see "Command-line usage"
|
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
|
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
|
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
|
`apikey` query-param auth rather than TMDB's Bearer token) — all
|
||||||
three disambiguated via
|
three disambiguated via
|
||||||
`similarity/` and tested against a mocked HTTP server 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,
|
rename engine (`rename/` — Move/Copy/Symlink actions,
|
||||||
Skip/Auto-number/Override conflict policies, can also organize
|
Skip/Auto-number/Override conflict policies, can also organize
|
||||||
into (auto-created) subfolders when
|
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
|
argument parsing/file expansion (`cli/` — no Tauri or network
|
||||||
dependency at all, see "Command-line usage" below). Not yet
|
dependency at all, see "Command-line usage" below). Not yet
|
||||||
ported: full media metadata bindings, other web datasource
|
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.
|
it builds and unit-tests standalone.
|
||||||
src-tauri/ Tauri app shell: exposes aurora-curator-core as commands to the frontend
|
src-tauri/ Tauri app shell: exposes aurora-curator-core as commands to the frontend
|
||||||
(naming-scheme rendering, filename matching for both TV and
|
(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)
|
is the stored movie source — independent of the TV source)
|
||||||
— executing a rename
|
— executing a rename
|
||||||
plan, native file/folder dialogs, drag-and-drop expansion), plus
|
plan, native file/folder dialogs, drag-and-drop expansion), plus
|
||||||
`settings.rs` (TMDB token, OMDb API key, and which TV *and* movie
|
`settings.rs` (TMDB token, OMDb API key, OpenSubtitles API key +
|
||||||
metadata source Fetch Data uses — two independent settings),
|
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
|
`history.rs` (rename history/undo
|
||||||
log), `presets.rs` (up to 9 saved naming schemes) — all three
|
log), `presets.rs` (up to 9 saved naming schemes) — all three
|
||||||
stored as JSON in the OS app-config directory — `cli.rs` (the
|
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
|
confirm-before-touching-disk modal for the actual
|
||||||
rename, a History panel that can undo a past rename, Align Up/Down
|
rename, a History panel that can undo a past rename, Align Up/Down
|
||||||
list reordering, and a Presets panel (plus `1`-`9` hotkeys) for
|
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/gen_icons.py Regenerates src-tauri/icons/ from the real logo mark
|
||||||
(`scripts/logo/icon-source.png`, cropped from the supplied
|
(`scripts/logo/icon-source.png`, cropped from the supplied
|
||||||
lockup) — replace that source image to change the look.
|
lockup) — replace that source image to change the look.
|
||||||
@@ -209,9 +227,10 @@ npm run tauri dev
|
|||||||
npm run tauri build
|
npm run tauri build
|
||||||
```
|
```
|
||||||
|
|
||||||
Verified in the environment this was built in: `cargo test --workspace` (all 128
|
Verified in the environment this was built in: `cargo test --workspace` (all 144
|
||||||
passing, including the TMDB, TVmaze, and OMDb client tests — those run against a local
|
passing, including the TMDB, TVmaze, OMDb, and OpenSubtitles client tests — those run
|
||||||
mock HTTP server via `mockito`, no live network or API key needed to build/test), `cargo
|
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
|
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
|
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.
|
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
|
organizing into a subfolder, and relaxing that invariant just for `--output` wasn't
|
||||||
judged worth it.
|
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)
|
## Porting roadmap (not yet done)
|
||||||
|
|
||||||
Roughly in priority order — each references the original FileBot source file it's
|
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
|
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
|
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
|
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
|
any source's top-ranked hit. **OpenSubtitles is also done, but as a subtitle source
|
||||||
yet started.
|
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
|
6. **Rename engine + CLI** — `net.filebot.cli.CmdlineOperations` (1235 lines) and
|
||||||
`ArgumentBean` (415 lines, the full flag surface: `-rename`, `--db`, `--format`,
|
`ArgumentBean` (415 lines, the full flag surface: `-rename`, `--db`, `--format`,
|
||||||
`--action`, `--conflict`, `-get-subtitles`, `-script`, etc.). The rename operation is
|
`--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
|
`-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,
|
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
|
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`/
|
Tauri `App`); see "Command-line usage" above. `--output <dir>` and `-script`/the rest
|
||||||
`-script`/the rest of FileBot's non-rename CLI modes are not ported — `-get-
|
of FileBot's non-rename CLI modes are not ported. `-get-subtitles` has no CLI analog
|
||||||
subtitles` and `-script` have no analog yet since subtitle-fetching and a script-mode
|
yet — subtitle fetching (see "Subtitles" above) is GUI-only so far, reached from its
|
||||||
runtime aren't built at all.
|
own sidebar panel rather than a command-line flag.
|
||||||
7. **The rest of the UI** — the Rename panel (file loading, drag-drop, live preview,
|
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,
|
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
|
Align Up/Down reordering, naming-scheme Presets with `1`-`9` hotkeys) is built, and so
|
||||||
other five sidebar panels (Episodes/Subtitles/SFV/Filter/List) are still stub
|
is the Subtitles panel (search + download via OpenSubtitles, see "Subtitles" above) —
|
||||||
screens. Align Up/Down is button-based (swaps the selected file with its neighbor),
|
the first of the other five sidebar panels to become a real screen. The remaining
|
||||||
not the original's drag-and-drop-within-a-list interaction.
|
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
|
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
|
full port of `net.filebot.HistoryDialog`'s session/element log
|
||||||
(`crates/aurora-curator-core/src/history`, `src-tauri/src/history.rs`). Every successful
|
(`crates/aurora-curator-core/src/history`, `src-tauri/src/history.rs`). Every successful
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
//! Online metadata clients ("Fetch Data"). TMDB (TV + movies), TVmaze (TV only, no API
|
//! Online metadata clients ("Fetch Data") plus, as of `opensubtitles`, a first
|
||||||
//! key needed), and OMDb (TV + movies, needs a free API key) so far; the original
|
//! non-metadata web client. TMDB (TV + movies), TVmaze (TV only, no API key needed),
|
||||||
//! FileBot supports ~40 sources (`net.filebot.web.*`) - TMDB was picked first since
|
//! and OMDb (TV + movies, needs a free API key) handle title/air-date lookups; the
|
||||||
//! it's the most widely useful for both TV and movies, TVmaze second since it's a
|
//! 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
|
//! 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
|
//! 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 omdb;
|
||||||
|
pub mod opensubtitles;
|
||||||
pub mod tmdb;
|
pub mod tmdb;
|
||||||
pub mod tvmaze;
|
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(¶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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ mod history;
|
|||||||
mod metadata;
|
mod metadata;
|
||||||
mod presets;
|
mod presets;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
mod subtitles;
|
||||||
|
|
||||||
use aurora_curator_core::format::ExpressionFormat;
|
use aurora_curator_core::format::ExpressionFormat;
|
||||||
use aurora_curator_core::matching::{self, EpisodeGuess, MovieGuess, ReleaseInfo};
|
use aurora_curator_core::matching::{self, EpisodeGuess, MovieGuess, ReleaseInfo};
|
||||||
@@ -107,6 +108,13 @@ pub fn run() {
|
|||||||
settings::save_tv_metadata_source,
|
settings::save_tv_metadata_source,
|
||||||
settings::get_movie_metadata_source,
|
settings::get_movie_metadata_source,
|
||||||
settings::save_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::list_rename_history,
|
||||||
history::undo_rename_batch,
|
history::undo_rename_batch,
|
||||||
presets::list_presets,
|
presets::list_presets,
|
||||||
|
|||||||
@@ -68,6 +68,21 @@ struct StoredSettings {
|
|||||||
tv_metadata_source: TvMetadataSource,
|
tv_metadata_source: TvMetadataSource,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
movie_metadata_source: MovieMetadataSource,
|
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> {
|
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)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -258,4 +360,49 @@ mod tests {
|
|||||||
assert_eq!(parsed.movie_metadata_source, MovieMetadataSource::Tmdb);
|
assert_eq!(parsed.movie_metadata_source, MovieMetadataSource::Tmdb);
|
||||||
assert_eq!(parsed.tv_metadata_source, TvMetadataSource::Tvmaze);
|
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()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(¶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<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
@@ -2,6 +2,7 @@ import { useState } from "react";
|
|||||||
import Sidebar, { PANELS } from "./components/Sidebar";
|
import Sidebar, { PANELS } from "./components/Sidebar";
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
import RenamePanel from "./components/RenamePanel";
|
import RenamePanel from "./components/RenamePanel";
|
||||||
|
import SubtitlesPanel from "./components/SubtitlesPanel";
|
||||||
import PanelStub from "./components/PanelStub";
|
import PanelStub from "./components/PanelStub";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -14,7 +15,13 @@ export default function App() {
|
|||||||
<div className="main-column">
|
<div className="main-column">
|
||||||
<Header title={current.name} />
|
<Header title={current.name} />
|
||||||
<div className="main-content">
|
<div className="main-content">
|
||||||
{selected === "rename" ? <RenamePanel /> : <PanelStub name={current.name} />}
|
{selected === "rename" ? (
|
||||||
|
<RenamePanel />
|
||||||
|
) : selected === "subtitles" ? (
|
||||||
|
<SubtitlesPanel />
|
||||||
|
) : (
|
||||||
|
<PanelStub name={current.name} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import "./App.css";
|
|||||||
import "./components/Sidebar.css";
|
import "./components/Sidebar.css";
|
||||||
import "./components/Header.css";
|
import "./components/Header.css";
|
||||||
import "./components/RenamePanel.css";
|
import "./components/RenamePanel.css";
|
||||||
|
import "./components/SubtitlesPanel.css";
|
||||||
import "./components/PanelStub.css";
|
import "./components/PanelStub.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
|
|||||||
Reference in New Issue
Block a user