From c158befa83c7567a620a02b68a73d149c0ca7710 Mon Sep 17 00:00:00 2001 From: Renamr Scaffold Date: Sat, 29 Aug 2026 12:25:45 +0000 Subject: [PATCH] Add OMDb as a datasource: TV episodes (third option alongside TMDB/TVmaze) and movies (new second option alongside TMDB) Movies get their own independent metadata-source setting for the first time (previously hardcoded to TMDB) so the GUI's Fetch Data Settings now has two separate pickers - TV episode source (TMDB/TVmaze/OMDb) and movie source (TMDB/OMDb) - and a batch can freely mix sources across its TV and movie halves. The -rename CLI's --db flag gained omdb alongside tmdb/tvmaze, with movies now looked up online for both tmdb and omdb. --- Cargo.lock | 1 + README.md | 119 ++-- crates/aurora-curator-core/src/cli/mod.rs | 31 +- crates/aurora-curator-core/src/web/mod.rs | 13 +- crates/aurora-curator-core/src/web/omdb.rs | 625 +++++++++++++++++++++ src-tauri/src/cli.rs | 90 ++- src-tauri/src/lib.rs | 4 + src-tauri/src/metadata.rs | 167 ++++-- src-tauri/src/settings.rs | 115 +++- src/components/RenamePanel.css | 8 + src/components/RenamePanel.tsx | 235 ++++++-- 11 files changed, 1234 insertions(+), 174 deletions(-) create mode 100644 crates/aurora-curator-core/src/web/omdb.rs diff --git a/Cargo.lock b/Cargo.lock index e3fa851..4cf0612 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,6 +105,7 @@ name = "aurora-curator" version = "0.1.0" dependencies = [ "aurora-curator-core", + "dirs", "serde", "serde_json", "tauri", diff --git a/README.md b/README.md index eef454f..8345659 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,11 @@ native file/folder loading and drag-and-drop, local filename-based season/episod movie title/year* detection ("Match") is working, real online metadata lookups ("Fetch Data") are working for both TV episodes and movies — including fuzzy title disambiguation, so a search that returns several similarly-named results doesn't just -take the source's top-ranked hit. TV episodes can resolve against either TMDB or -TVmaze — user-selectable in Fetch Data Settings, TMDB stays the default and TVmaze -needs no API token at all — while movies always resolve against TMDB, since TVmaze has -no movie catalog. The actual on-disk operation is wired up (with a +take the source's top-ranked hit. TV episodes can resolve against TMDB, TVmaze, or +OMDb, and movies against TMDB or OMDb — each picked independently in Fetch Data +Settings, TMDB stays the default for both, TVmaze needs no API token at all (it has no +movie catalog, so it's TV-only), and TMDB/OMDb each need their own free saved +credential before a batch resolved against them will succeed. The actual on-disk operation is wired up (with a confirmation step showing the full old-path/new-path plan before anything touches disk, a per-row ✕ to drop an individual file from that plan at the last step, a Move/Copy/ Symlink selector for what to actually do at each target, and — independently — a Skip/ @@ -135,10 +136,13 @@ crates/aurora-curator-core/ Pure Rust core: naming-expression engine (ported), the same module (`guess_release_info` — resolution, source, video/ audio codec, release group), text-similarity scoring for disambiguating search results (`similarity/`, partial - `EpisodeMetrics` port), two web metadata clients — `web/tmdb.rs` - (TV search + episode lookup and movie search + lookup) and + `EpisodeMetrics` port), three web metadata clients — `web/tmdb.rs` + (TV search + episode lookup and movie search + lookup), `web/tvmaze.rs` (TV search + episode lookup only — TVmaze has no - movie catalog, and no API key/auth at all), both disambiguated via + movie catalog, and no API key/auth at all), and `web/omdb.rs` (TV + *and* movie search + lookup, single flat root-URL REST API, plain + `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 rename engine (`rename/` — Move/Copy/Symlink actions, @@ -150,16 +154,19 @@ 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/OMDb/etc). No Tauri dependency, so it - builds and unit-tests standalone. + clients (TheTVDB/AniDB/OpenSubtitles/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 movies, metadata fetch — `metadata.rs` for TV episodes (against - whichever of TMDB/TVmaze is the stored source) plus a - movie-fetch command alongside it (always TMDB) — executing a rename + whichever of TMDB/TVmaze/OMDb is the stored TV source) plus a + movie-fetch command alongside it (against whichever of TMDB/OMDb + 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, and which TV metadata source Fetch Data - uses), `history.rs` (rename history/undo + `settings.rs` (TMDB token, OMDb API key, and which TV *and* movie + metadata source Fetch Data uses — two independent settings), + `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 `-rename` command-line mode's orchestration: builds and executes a @@ -171,7 +178,9 @@ src/ React + TypeScript UI (Vite). Faithful recreation of the two-pane rename screen (Aurora theme) wired to real file loading, drag-and-drop, live naming-scheme preview, local TV/movie matching, metadata fetch for both (behind a Fetch Data Settings - modal holding the TMDB/TVmaze source picker and the API token), a + modal holding independent TV-source (TMDB/TVmaze/OMDb) and + movie-source (TMDB/OMDb) pickers plus the TMDB token and OMDb key + inputs), a 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 @@ -200,13 +209,13 @@ npm run tauri dev npm run tauri build ``` -Verified in the environment this was built in: `cargo test -p aurora-curator-core` (all -passing, including the TMDB and TVmaze 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. Run `npm run tauri dev` -on your machine to see and click through it for the first time. +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 +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. +Run `npm run tauri dev` on your machine to see and click through it for the first time. ### Setting up metadata sources (for "Fetch Data") @@ -214,22 +223,34 @@ Everything else in the app works fully offline; "Fetch Data" is the one feature talks to the network, and it's the only one that needs any setup at all — and even that setup is optional for TV. -TV episodes can resolve against either of two sources, picked in the app (click the key -icon, "Fetch Data Settings", next to Fetch Data): +TV episodes and movies each have their own independent source picker in the app (click +the key icon, "Fetch Data Settings", next to Fetch Data) — a batch can freely mix TV and +movie files resolved against two different sources at once: -- **[TVmaze](https://www.tvmaze.com/)** — free, no account or API token needed at all. - Just pick it and go. -- **[TMDB](https://www.themoviedb.org/)** — the default, and the only option for movies - (TVmaze has no movie catalog). Needs a free account: +- **[TVmaze](https://www.tvmaze.com/)** — TV only (it has no movie catalog). Free, no + account or API token needed at all. Just pick it and go. +- **[TMDB](https://www.themoviedb.org/)** — the default for both TV and movies. Needs a + free account: 1. Create a TMDB account, then go to Settings → API in your account and generate an **API Read Access Token** (not the older numeric "API Key" — Aurora Curator uses the Bearer token method TMDB documents as the default). 2. In the app, open "Fetch Data Settings" and paste the token in. It's saved locally in the OS app-config directory and only ever sent to `api.themoviedb.org`. +- **[OMDb](https://www.omdbapi.com/)** — TV and movies, an alternative to TMDB for + either or both. Needs a free API key: + 1. Request a free key at (OMDb emails it to you). + 2. In the app, open "Fetch Data Settings" and paste the key into the OMDb field. It's + saved locally in the OS app-config directory and only ever sent to + `www.omdbapi.com`. -A batch of TV-only files can run entirely on TVmaze with no token configured at all; a -batch containing any movies still needs the TMDB token regardless of which TV source is -selected. +A batch of TV-only files can run entirely on TVmaze with no credential configured at +all. Otherwise, whichever of TMDB/OMDb is actually selected — for TV, for movies, or +both — needs its own credential saved first; picking TMDB for TV and OMDb for movies (or +any other mix) only needs the two credentials that combination actually uses. Note OMDb +returns a normal-looking (HTTP 200) "no results" response for both a genuine no-match and +an invalid API key, so a bad key currently surfaces as "not found" rather than a clearer +credential error — a known OMDb API limitation, not something Aurora Curator can +distinguish from its response alone. ## Command-line usage @@ -248,14 +269,15 @@ aurora-curator -rename --format "{show name} - {season number}x{episode number} aliases (`{show name}`, `{episode name}`, `{movie name}`, `{resolution}`, `{video codec}`, `{release group}`, …), or real Rhai script. `{name}`/`{ext}` fall back to the file's own current filename/extension when nothing else matched it. -- `--db ` (optional) — resolves TV episodes against that online source +- `--db ` (optional) — resolves TV episodes against that online source before rendering names, the command-line equivalent of clicking "Fetch Data" after - Match. Movies are only looked up online when this is `tmdb` — TVmaze has no movie - catalog, and with no `--db` at all every file uses local filename matching only, same - as Match without Fetch Data in the GUI. `--db tmdb` needs a TMDB token that's already + Match. Movies are also looked up online whenever this is `tmdb` or `omdb` — TVmaze has + no movie catalog, so `--db tvmaze` only affects the TV half of a batch — and with no + `--db` at all every file uses local filename matching only, same as Match without Fetch + Data in the GUI. `--db tmdb`/`--db omdb` each need the matching token/key that's already been saved through the app's own Fetch Data Settings at least once — **the CLI never - accepts an API token as an argument**, so there's nothing secret to leak into your - shell history or a script. + accepts an API token or key as an argument**, so there's nothing secret to leak into + your shell history or a script. - `--action ` (default `move`) — what to do at each computed target; `test` prints the full plan and touches nothing on disk, useful for checking a naming scheme before committing to it. @@ -312,21 +334,28 @@ ported from, for anyone picking this up: 4. **Media detection** — `net.filebot.media.MediaDetection` (1356 lines): ties file-name parsing + matching together to auto-identify what a file is. 5. **Web datasource clients** — `net.filebot.web.*` (~40 files): TheMovieDB, TheTVDB, - AniDB, OMDb, TVmaze, OpenSubtitles, etc. Two are done: TMDB, for both TV and movies + AniDB, OMDb, TVmaze, OpenSubtitles, etc. Three are done: TMDB, for both TV and movies (`crates/aurora-curator-core/src/web/tmdb.rs`, `reqwest` + `serde_json`, Bearer-token auth) — search-by-name plus per-season episode lookup for TV, search-by-title (optionally narrowed by release year via TMDB's own `primary_release_year` filter) for movies — - and TVmaze, for TV only (`crates/aurora-curator-core/src/web/tvmaze.rs`, same `reqwest` + + TVmaze, for TV only (`crates/aurora-curator-core/src/web/tvmaze.rs`, same `reqwest` + `serde_json` shape, no auth at all — TVmaze has no movie catalog to search, and its per-show episode endpoint returns every season in one flat list rather than TMDB's per-season lookup, so the client fetches once and filters locally, skipping any - unnumbered/special episodes rather than panicking on them). Both are wired to "Fetch - Data" — a user-selectable "TV episode source" picker in Fetch Data Settings chooses - which of the two a batch's TV items resolve against (TMDB stays the default; movies - always use TMDB regardless of that picker) — disambiguating multi-result searches via + unnumbered/special episodes rather than panicking on them) — and OMDb, for both TV + and movies (`crates/aurora-curator-core/src/web/omdb.rs`, same `reqwest` + `serde_json` + shape but a single flat root-URL REST API rather than TMDB's per-resource paths, plain + `apikey` query-param auth rather than a Bearer token, and human-text release dates + (`"16 Jul 2010"`/`"N/A"`) normalized to ISO form locally; a movie search result only + carries a bare year, so `lookup_movie` follows up with a by-ID detail request to get + the actual release date). All three are wired to "Fetch + Data" — independent "TV episode source" (TMDB/TVmaze/OMDb) and "Movie source" + (TMDB/OMDb) pickers in Fetch Data Settings choose which source a batch's TV and movie + 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 - either source's top-ranked hit. All other sources (TheTVDB, AniDB, OMDb, - OpenSubtitles) are not yet started. + any source's top-ranked hit. All other sources (TheTVDB, AniDB, OpenSubtitles) are not + yet started. 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 @@ -348,7 +377,7 @@ ported from, for anyone picking this up: CLI flag surface is done too — `-rename`/`--format`/`--db`/`--action`/`--conflict`/ `-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 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 ` 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 diff --git a/crates/aurora-curator-core/src/cli/mod.rs b/crates/aurora-curator-core/src/cli/mod.rs index 4d33b9a..2655aa8 100644 --- a/crates/aurora-curator-core/src/cli/mod.rs +++ b/crates/aurora-curator-core/src/cli/mod.rs @@ -24,16 +24,18 @@ use crate::rename::{ConflictPolicy, RenameAction}; use std::path::{Path, PathBuf}; -/// Which online source `--db` should resolve TV episodes against. Deliberately its own -/// small enum rather than reusing the Tauri crate's `settings::TvMetadataSource` (this -/// crate can't depend on that crate, and that enum represents a *persisted GUI -/// preference* anyway, not a per-invocation flag) - a CLI run always states its source -/// explicitly via `--db` when it wants one; it never falls back to whatever the GUI's -/// Fetch Data Settings last had selected. +/// Which online source `--db` should resolve TV episodes (and, for `Tmdb`/`Omdb`, +/// movies too - see `src-tauri/src/cli.rs`'s `run_rename`) against. Deliberately its +/// own small enum rather than reusing the Tauri crate's `settings::TvMetadataSource`/ +/// `MovieMetadataSource` (this crate can't depend on that crate, and those enums +/// represent *persisted GUI preferences* anyway, not a per-invocation flag) - a CLI run +/// always states its source explicitly via `--db` when it wants one; it never falls +/// back to whatever the GUI's Fetch Data Settings last had selected. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TvDbChoice { Tmdb, Tvmaze, + Omdb, } /// A fully parsed, valid `-rename` invocation. @@ -117,9 +119,10 @@ pub fn parse_args(args: &[String]) -> Result { db = Some(match value.to_ascii_lowercase().as_str() { "tmdb" => TvDbChoice::Tmdb, "tvmaze" => TvDbChoice::Tvmaze, + "omdb" => TvDbChoice::Omdb, other => { return Err(CliError(format!( - "unknown --db source \"{other}\" (expected tmdb or tvmaze)" + "unknown --db source \"{other}\" (expected tmdb, tvmaze, or omdb)" ))) } }); @@ -298,6 +301,20 @@ mod tests { other => panic!("expected Rename, got {other:?}"), } + let parsed = parse_args(&[ + s("-rename"), + s("--format"), + s("{n}"), + s("--db"), + s("OMDB"), + s("file.mkv"), + ]) + .unwrap(); + match parsed { + CliInvocation::Rename(args) => assert_eq!(args.db, Some(TvDbChoice::Omdb)), + other => panic!("expected Rename, got {other:?}"), + } + let err = parse_args(&[ s("-rename"), s("--format"), diff --git a/crates/aurora-curator-core/src/web/mod.rs b/crates/aurora-curator-core/src/web/mod.rs index 1c0442f..c1b1fc6 100644 --- a/crates/aurora-curator-core/src/web/mod.rs +++ b/crates/aurora-curator-core/src/web/mod.rs @@ -1,8 +1,11 @@ -//! Online metadata clients ("Fetch Data"). TMDB (TV + movies) and TVmaze (TV only, no -//! API key needed) 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 zero-setup second TV source (see -//! `tvmaze`'s module doc for why movies aren't in scope for it). +//! 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 +//! 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). +pub mod omdb; pub mod tmdb; pub mod tvmaze; diff --git a/crates/aurora-curator-core/src/web/omdb.rs b/crates/aurora-curator-core/src/web/omdb.rs new file mode 100644 index 0000000..91e233d --- /dev/null +++ b/crates/aurora-curator-core/src/web/omdb.rs @@ -0,0 +1,625 @@ +//! A client for [OMDb](https://www.omdbapi.com/)'s REST API: search for a TV show or +//! movie by name, then look up real metadata (a specific episode's title/air date, or a +//! movie's canonical title/release date). +//! +//! A third metadata source alongside TMDB and TVmaze - and, unlike TVmaze, one that +//! covers **both** movies and TV, same as TMDB, so it slots into both of Fetch Data's +//! source pickers rather than just the TV one. Needs a free OMDb API key +//! (), sent as a plain `apikey` query parameter on +//! every request - OMDb has no Bearer-token scheme like TMDB, just the one flat query +//! param, and (unlike TMDB's REST-path-per-resource shape) every OMDb request hits the +//! same root URL with different query parameters (`s=` search, `i=`/`t=` detail lookup, +//! `i=`+`Season=` episode listing). +//! +//! OMDb's own date fields (`Released`, both on movie details and on episodes) come back +//! as human text like `"16 Jul 2010"`, or the literal string `"N/A"` when unknown - see +//! [`parse_release_date`], which normalizes that into the same `YYYY-MM-DD` shape +//! TMDB/TVmaze already use, so the Tauri glue and the naming-scheme `{airdate}` binding +//! don't need to know which source produced a result. +//! +//! Known limitation, deliberately not engineered around: OMDb returns HTTP 200 (not an +//! error status) for both "no results" and "invalid API key" - `{"Response":"False", +//! "Error":"..."}` either way - so this client can't reliably distinguish "nothing +//! matched" from "your key is wrong" the way TMDB's real 401 lets `TmdbError::Api` +//! surface a bad token distinctly. Both cases fall through to [`OmdbError::NotFound`] +//! here, same as an empty TMDB/TVmaze result set; a user with a broken key just sees "no +//! match found" repeatedly rather than a specific "invalid key" message. + +use serde::Deserialize; +use thiserror::Error; + +use crate::similarity::best_title_match; + +const DEFAULT_BASE_URL: &str = "https://www.omdbapi.com"; + +/// Same threshold TMDB/TVmaze use (see `tmdb::NAME_MATCH_THRESHOLD`) - kept identical +/// so switching sources doesn't change how forgiving/strict name matching feels. +const NAME_MATCH_THRESHOLD: f64 = 0.35; + +#[derive(Debug, Error)] +pub enum OmdbError { + #[error("network error talking to OMDb: {0}")] + Network(String), + #[error("OMDb API error (status {status}): {message}")] + Api { status: u16, message: String }, + #[error("no OMDb match found for \"{0}\"")] + NotFound(String), +} + +/// Converts OMDb's `"16 Jul 2010"`-style date text into `YYYY-MM-DD` (matching what +/// TMDB/TVmaze already return), or `None` for `"N/A"`/empty/anything unparseable. +fn parse_release_date(raw: &str) -> Option { + if raw.trim().is_empty() || raw.eq_ignore_ascii_case("n/a") { + return None; + } + let parts: Vec<&str> = raw.split_whitespace().collect(); + let [day, month, year] = parts[..] else { return None }; + let day: u32 = day.parse().ok()?; + let month = month_number(month)?; + let year: u32 = year.parse().ok()?; + Some(format!("{year:04}-{month:02}-{day:02}")) +} + +fn month_number(name: &str) -> Option { + Some(match name.to_ascii_lowercase().as_str() { + "jan" => 1, + "feb" => 2, + "mar" => 3, + "apr" => 4, + "may" => 5, + "jun" => 6, + "jul" => 7, + "aug" => 8, + "sep" => 9, + "oct" => 10, + "nov" => 11, + "dec" => 12, + _ => return None, + }) +} + +#[derive(Debug, Clone, Deserialize)] +struct SearchResponse { + #[serde(default, rename = "Search")] + search: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct SearchHit { + #[serde(rename = "Title")] + title: String, + #[serde(rename = "imdbID")] + imdb_id: String, +} + +/// One OMDb TV-show search hit. Only the fields Aurora Curator actually uses are kept. +#[derive(Debug, Clone)] +pub struct TvResult { + pub imdb_id: String, + pub name: String, +} + +/// One OMDb movie search hit. +#[derive(Debug, Clone)] +pub struct MovieResult { + pub imdb_id: String, + pub title: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct SeasonResponse { + #[serde(default, rename = "Episodes")] + episodes: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct EpisodeHit { + #[serde(rename = "Title")] + title: String, + /// A numeric string (`"1"`), but occasionally `"N/A"` for an uncategorized special + /// - parsed defensively in `season_episodes`, same "skip rather than panic" + /// convention TVmaze's null-`number` episodes use. + #[serde(rename = "Episode")] + episode: String, + #[serde(rename = "Released", default)] + released: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct DetailResponse { + #[serde(rename = "Released", default)] + released: String, +} + +/// Real metadata for one episode, resolved from OMDb - same shape as +/// `tmdb::EpisodeInfo`/`tvmaze::EpisodeInfo` so the Tauri glue can treat any source's +/// result identically. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EpisodeInfo { + pub series_name: String, + pub episode_title: String, + pub air_date: Option, +} + +/// One episode of a season, as returned by `season_episodes()` - mirrors +/// `tmdb::SeasonEpisode`/`tvmaze::SeasonEpisode`'s field names. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SeasonEpisode { + pub episode_number: i32, + pub title: String, + pub air_date: Option, +} + +/// Real metadata for one movie, resolved from OMDb - mirrors `tmdb::MovieInfo`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MovieInfo { + pub title: String, + pub release_date: Option, +} + +pub struct OmdbClient { + http: reqwest::Client, + api_key: String, + base_url: String, +} + +impl OmdbClient { + pub fn new(api_key: impl Into) -> Self { + Self { + http: reqwest::Client::new(), + api_key: api_key.into(), + base_url: DEFAULT_BASE_URL.to_string(), + } + } + + /// Overrides the API base URL - used by tests to point at a local mock server + /// instead of the real OMDb API. + pub fn with_base_url(mut self, base_url: impl Into) -> Self { + self.base_url = base_url.into(); + self + } + + /// Every OMDb request hits the same root URL with different query parameters (no + /// per-resource paths like TMDB's `/search/movie`) - `apikey` is appended here so + /// every call site gets it for free rather than repeating it. + async fn get_json Deserialize<'de>>(&self, query: &[(&str, &str)]) -> Result { + let mut full_query: Vec<(&str, &str)> = query.to_vec(); + full_query.push(("apikey", self.api_key.as_str())); + + let response = self + .http + .get(&self.base_url) + .query(&full_query) + .send() + .await + .map_err(|e| OmdbError::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(OmdbError::Api { status, message }); + } + + response + .json::() + .await + .map_err(|e| OmdbError::Network(e.to_string())) + } + + /// Searches for a TV show by name. Results come back OMDb-ranked; callers wanting + /// "the best guess" should use `search_tv_best_match` instead, same convention as + /// the TMDB/TVmaze clients. + pub async fn search_tv(&self, query: &str) -> Result, OmdbError> { + let parsed: SearchResponse = self.get_json(&[("s", query), ("type", "series")]).await?; + Ok(parsed + .search + .into_iter() + .map(|h| TvResult { imdb_id: h.imdb_id, name: h.title }) + .collect()) + } + + /// Searches for a TV show by name and returns the single best-matching result (see + /// `similarity::best_title_match`), or `None` if nothing is a plausible match at + /// all. Mirrors `TmdbClient::search_tv_best_match`/`TvmazeClient::search_tv_best_match`. + pub async fn search_tv_best_match(&self, query: &str) -> Result, OmdbError> { + let results = self.search_tv(query).await?; + Ok(best_title_match(query, &results, |r| r.name.as_str(), NAME_MATCH_THRESHOLD).cloned()) + } + + /// Fetches one season's episodes for a known OMDb (IMDb) series id. + pub async fn season_episodes(&self, series_imdb_id: &str, season: i32) -> Result, OmdbError> { + let season_str = season.to_string(); + let parsed: SeasonResponse = self.get_json(&[("i", series_imdb_id), ("Season", &season_str)]).await?; + + Ok(parsed + .episodes + .into_iter() + .filter_map(|e| { + let episode_number = e.episode.parse::().ok()?; + Some(SeasonEpisode { + episode_number, + title: e.title, + air_date: parse_release_date(&e.released), + }) + }) + .collect()) + } + + /// Convenience: search for the best-matching show by name, then fetch a specific + /// episode. Same disambiguation approach as the TMDB/TVmaze clients - picks + /// whichever result's name is textually closest to `series_query`, and reports + /// `NotFound` instead of guessing when nothing is a plausible match. + pub async fn lookup_episode( + &self, + series_query: &str, + season: i32, + episode: i32, + ) -> Result { + let best = self + .search_tv_best_match(series_query) + .await? + .ok_or_else(|| OmdbError::NotFound(series_query.to_string()))?; + + let episodes = self.season_episodes(&best.imdb_id, season).await?; + let found = episodes + .into_iter() + .find(|e| e.episode_number == episode) + .ok_or_else(|| OmdbError::NotFound(format!("S{season:02}E{episode:02}")))?; + + Ok(EpisodeInfo { + series_name: best.name, + episode_title: found.title, + air_date: found.air_date, + }) + } + + /// Searches for a movie by title. OMDb's search endpoint doesn't take a release-year + /// filter the way TMDB's does, so `year` is accepted for signature parity with + /// `TmdbClient::search_movie` (name-similarity disambiguation alone is what actually + /// picks the best result) - OMDb does support a `y=` search parameter, so it's used + /// here to narrow the search server-side when a year is known, same intent as TMDB's + /// `primary_release_year`. + pub async fn search_movie(&self, query: &str, year: Option) -> Result, OmdbError> { + let year_str; + let mut params: Vec<(&str, &str)> = vec![("s", query), ("type", "movie")]; + if let Some(y) = year { + year_str = y.to_string(); + params.push(("y", &year_str)); + } + let parsed: SearchResponse = self.get_json(¶ms).await?; + Ok(parsed + .search + .into_iter() + .map(|h| MovieResult { imdb_id: h.imdb_id, title: h.title }) + .collect()) + } + + /// Convenience: search for the best-matching movie by title (and optional release + /// year), then fetch its full detail record for a real release date - OMDb's search + /// results only carry a bare `Year`, not a full date, unlike TMDB's search results. + pub async fn lookup_movie(&self, query: &str, year: Option) -> Result { + let results = self.search_movie(query, year).await?; + let best = best_title_match(query, &results, |r| r.title.as_str(), NAME_MATCH_THRESHOLD) + .ok_or_else(|| OmdbError::NotFound(query.to_string()))?; + + let detail: DetailResponse = self.get_json(&[("i", best.imdb_id.as_str())]).await?; + Ok(MovieInfo { + title: best.title.clone(), + release_date: parse_release_date(&detail.released), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_omdb_style_dates_into_iso_form() { + assert_eq!(parse_release_date("16 Jul 2010"), Some("2010-07-16".to_string())); + assert_eq!(parse_release_date("01 Jan 2008"), Some("2008-01-01".to_string())); + } + + #[test] + fn treats_na_and_empty_dates_as_unknown() { + assert_eq!(parse_release_date("N/A"), None); + assert_eq!(parse_release_date("n/a"), None); + assert_eq!(parse_release_date(""), None); + assert_eq!(parse_release_date("not a date"), None); + } + + #[tokio::test] + async fn looks_up_a_real_episode_end_to_end() { + let mut server = mockito::Server::new_async().await; + + let _search_mock = server + .mock("GET", "/") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("s".into(), "Girls".into()), + mockito::Matcher::UrlEncoded("type".into(), "series".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"Search":[{"Title":"Girls","imdbID":"tt1723816"}],"Response":"True"}"#) + .create_async() + .await; + + let _season_mock = server + .mock("GET", "/") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("i".into(), "tt1723816".into()), + mockito::Matcher::UrlEncoded("Season".into(), "2".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"Episodes":[ + {"Title":"It's About Time","Episode":"1","Released":"13 Jan 2013"}, + {"Title":"I Get Ideas","Episode":"2","Released":"20 Jan 2013"} + ],"Response":"True"}"#, + ) + .create_async() + .await; + + let client = OmdbClient::new("test-key").with_base_url(server.url()); + let info = client.lookup_episode("Girls", 2, 2).await.unwrap(); + + assert_eq!( + info, + EpisodeInfo { + series_name: "Girls".to_string(), + episode_title: "I Get Ideas".to_string(), + air_date: Some("2013-01-20".to_string()), + } + ); + } + + #[tokio::test] + async fn reports_not_found_when_search_has_no_results() { + let mut server = mockito::Server::new_async().await; + + let _mock = server + .mock("GET", "/") + .match_query(mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"Response":"False","Error":"Series not found!"}"#) + .create_async() + .await; + + let client = OmdbClient::new("test-key").with_base_url(server.url()); + let result = client.lookup_episode("Some Nonexistent Show", 1, 1).await; + + assert!(matches!(result, Err(OmdbError::NotFound(_)))); + } + + #[tokio::test] + async fn reports_not_found_when_the_season_has_no_such_episode() { + let mut server = mockito::Server::new_async().await; + + let _search_mock = server + .mock("GET", "/") + .match_query(mockito::Matcher::UrlEncoded("s".into(), "Some Show".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"Search":[{"Title":"Some Show","imdbID":"tt0000042"}],"Response":"True"}"#) + .create_async() + .await; + + let _season_mock = server + .mock("GET", "/") + .match_query(mockito::Matcher::UrlEncoded("i".into(), "tt0000042".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"Episodes":[{"Title":"Pilot","Episode":"1","Released":"01 Jan 2020"}],"Response":"True"}"#) + .create_async() + .await; + + let client = OmdbClient::new("test-key").with_base_url(server.url()); + // Season 1 only has episode 1 in this mock - asking for episode 99 should miss. + let result = client.lookup_episode("Some Show", 1, 99).await; + + assert!(matches!(result, Err(OmdbError::NotFound(_)))); + } + + #[tokio::test] + async fn picks_the_exact_name_match_over_a_more_popular_but_wrong_result() { + let mut server = mockito::Server::new_async().await; + + let _search_mock = server + .mock("GET", "/") + .match_query(mockito::Matcher::UrlEncoded("s".into(), "Chuck".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"Search":[ + {"Title":"Chucky","imdbID":"tt1"}, + {"Title":"Chuck","imdbID":"tt2"} + ],"Response":"True"}"#, + ) + .create_async() + .await; + + let _season_mock = server + .mock("GET", "/") + .match_query(mockito::Matcher::UrlEncoded("i".into(), "tt2".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"Episodes":[{"Title":"Chuck Versus the Intersect","Episode":"1","Released":"24 Sep 2007"}],"Response":"True"}"#, + ) + .create_async() + .await; + + let client = OmdbClient::new("test-key").with_base_url(server.url()); + let info = client.lookup_episode("Chuck", 1, 1).await.unwrap(); + + assert_eq!(info.series_name, "Chuck"); + assert_eq!(info.episode_title, "Chuck Versus the Intersect"); + } + + #[tokio::test] + async fn reports_not_found_when_no_search_result_is_a_plausible_name_match() { + let mut server = mockito::Server::new_async().await; + + let _mock = server + .mock("GET", "/") + .match_query(mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"Search":[{"Title":"Totally Unrelated Program","imdbID":"tt9"}],"Response":"True"}"#) + .create_async() + .await; + + let client = OmdbClient::new("test-key").with_base_url(server.url()); + let result = client.lookup_episode("Zzyzx Nonexistent Show", 1, 1).await; + + assert!(matches!(result, Err(OmdbError::NotFound(_)))); + } + + #[tokio::test] + async fn episodes_with_an_unparseable_episode_number_are_skipped_rather_than_panicking() { + let mut server = mockito::Server::new_async().await; + + let _search_mock = server + .mock("GET", "/") + .match_query(mockito::Matcher::UrlEncoded("s".into(), "Some Show".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"Search":[{"Title":"Some Show","imdbID":"tt7"}],"Response":"True"}"#) + .create_async() + .await; + + let _season_mock = server + .mock("GET", "/") + .match_query(mockito::Matcher::UrlEncoded("i".into(), "tt7".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"Episodes":[ + {"Title":"Uncategorized Special","Episode":"N/A","Released":"N/A"}, + {"Title":"Pilot","Episode":"1","Released":"01 Jan 2021"} + ],"Response":"True"}"#, + ) + .create_async() + .await; + + let client = OmdbClient::new("test-key").with_base_url(server.url()); + let info = client.lookup_episode("Some Show", 1, 1).await.unwrap(); + + assert_eq!(info.episode_title, "Pilot"); + } + + #[tokio::test] + async fn looks_up_a_real_movie_end_to_end() { + let mut server = mockito::Server::new_async().await; + + let _search_mock = server + .mock("GET", "/") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("s".into(), "Inception".into()), + mockito::Matcher::UrlEncoded("type".into(), "movie".into()), + mockito::Matcher::UrlEncoded("y".into(), "2010".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"Search":[{"Title":"Inception","imdbID":"tt1375666"}],"Response":"True"}"#) + .create_async() + .await; + + let _detail_mock = server + .mock("GET", "/") + .match_query(mockito::Matcher::UrlEncoded("i".into(), "tt1375666".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"Title":"Inception","Released":"16 Jul 2010","Response":"True"}"#) + .create_async() + .await; + + let client = OmdbClient::new("test-key").with_base_url(server.url()); + let info = client.lookup_movie("Inception", Some(2010)).await.unwrap(); + + assert_eq!( + info, + MovieInfo { + title: "Inception".to_string(), + release_date: Some("2010-07-16".to_string()), + } + ); + } + + #[tokio::test] + async fn picks_the_exact_movie_title_match_over_a_more_popular_but_wrong_result() { + let mut server = mockito::Server::new_async().await; + + let _search_mock = server + .mock("GET", "/") + .match_query(mockito::Matcher::UrlEncoded("s".into(), "Up".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"Search":[ + {"Title":"Wake Up","imdbID":"tt1"}, + {"Title":"Up","imdbID":"tt2"} + ],"Response":"True"}"#, + ) + .create_async() + .await; + + let _detail_mock = server + .mock("GET", "/") + .match_query(mockito::Matcher::UrlEncoded("i".into(), "tt2".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"Title":"Up","Released":"29 May 2009","Response":"True"}"#) + .create_async() + .await; + + let client = OmdbClient::new("test-key").with_base_url(server.url()); + let info = client.lookup_movie("Up", None).await.unwrap(); + + assert_eq!(info.title, "Up"); + assert_eq!(info.release_date, Some("2009-05-29".to_string())); + } + + #[tokio::test] + async fn reports_not_found_when_movie_search_has_no_results() { + let mut server = mockito::Server::new_async().await; + + let _mock = server + .mock("GET", "/") + .match_query(mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"Response":"False","Error":"Movie not found!"}"#) + .create_async() + .await; + + let client = OmdbClient::new("test-key").with_base_url(server.url()); + let result = client.lookup_movie("Some Nonexistent Movie", None).await; + + assert!(matches!(result, Err(OmdbError::NotFound(_)))); + } + + #[tokio::test] + async fn surfaces_a_server_error_as_an_api_error() { + let mut server = mockito::Server::new_async().await; + + let _mock = server + .mock("GET", "/") + .match_query(mockito::Matcher::Any) + .with_status(503) + .with_header("content-type", "text/plain") + .with_body("service unavailable") + .create_async() + .await; + + let client = OmdbClient::new("test-key").with_base_url(server.url()); + let result = client.search_tv("anything").await; + + match result { + Err(OmdbError::Api { status, .. }) => assert_eq!(status, 503), + other => panic!("expected Api error, got {other:?}"), + } + } +} diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index 57fedca..6d9c057 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -25,12 +25,13 @@ use aurora_curator_core::format::ExpressionFormat; use aurora_curator_core::matching::{self, ReleaseInfo}; use aurora_curator_core::rename::{self, RenameOutcome, RenamePlanItem}; use aurora_curator_core::string_bindings; +use aurora_curator_core::web::omdb::OmdbClient; use aurora_curator_core::web::tmdb::TmdbClient; use aurora_curator_core::web::tvmaze::TvmazeClient; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use crate::metadata::AnyTvClient; +use crate::metadata::{AnyMovieClient, AnyTvClient, MovieLookup}; use crate::settings; const USAGE: &str = "\ @@ -41,16 +42,16 @@ Usage: Options: --format Naming scheme, e.g. \"{show name} - {season number}x{episode number} - {episode name}\" (required) - --db Resolve TV episodes online against this source before rendering names (optional; - movies are only looked up online when this is tmdb - TVmaze has no movie catalog) + --db Resolve TV episodes online against this source before rendering names (optional; + movies are also looked up online when this is tmdb or omdb - TVmaze has no movie catalog) --action What to do with each file (default: move; \"test\" prints the plan without touching anything) --conflict What to do when a computed target already exists (default: skip) -non-strict Accepted for FileBot script compatibility; has no effect (local matching has no confidence score to loosen - see the aurora_curator_core::cli module doc) -h, --help Show this help and exit -A --db source needs a TMDB token already saved through the app's own Fetch Data Settings at least once - -the CLI never accepts a token as a command-line argument. +A --db tmdb/omdb source needs the matching API token/key already saved through the app's own Fetch Data +Settings at least once - the CLI never accepts a token or key as a command-line argument. "; /// Entry point called from `main.rs`. Returns the process exit code. @@ -93,8 +94,8 @@ fn run_rename(args: RenameCliArgs) -> i32 { }; // Resolved once up front (not per-file) so a batch never ends up split across two - // different saved-token states, and so a missing token fails fast instead of after - // however many files were already processed. + // different saved-token states, and so a missing token/key fails fast instead of + // after however many files were already processed. let tmdb_token = if args.db == Some(TvDbChoice::Tmdb) { match settings::cli_tmdb_token() { Some(token) => Some(token), @@ -108,6 +109,19 @@ fn run_rename(args: RenameCliArgs) -> i32 { } else { None }; + let omdb_api_key = if args.db == Some(TvDbChoice::Omdb) { + match settings::cli_omdb_api_key() { + Some(key) => Some(key), + None => { + eprintln!( + "error: --db omdb needs an OMDb API key already saved through the app's own Fetch Data Settings" + ); + return 2; + } + } + } else { + None + }; let tv_client = match args.db { None => None, @@ -115,10 +129,22 @@ fn run_rename(args: RenameCliArgs) -> i32 { Some(TvDbChoice::Tmdb) => Some(AnyTvClient::Tmdb(TmdbClient::new( tmdb_token.clone().expect("checked above"), ))), + Some(TvDbChoice::Omdb) => Some(AnyTvClient::Omdb(OmdbClient::new( + omdb_api_key.clone().expect("checked above"), + ))), + }; + // Movies get an online lookup whenever --db tmdb or --db omdb was explicitly + // passed - TVmaze has no movie catalog, and no --db at all means "local matching + // only" for both. + let movie_client = match args.db { + Some(TvDbChoice::Tmdb) => Some(AnyMovieClient::Tmdb(TmdbClient::new( + tmdb_token.clone().expect("checked above"), + ))), + Some(TvDbChoice::Omdb) => Some(AnyMovieClient::Omdb(OmdbClient::new( + omdb_api_key.clone().expect("checked above"), + ))), + Some(TvDbChoice::Tvmaze) | None => None, }; - // Movies only get an online lookup when --db tmdb was explicitly passed - TVmaze - // has no movie catalog, and no --db at all means "local matching only" for both. - let movie_client = tmdb_token.map(TmdbClient::new); let items = tauri::async_runtime::block_on(build_plan(&files, &compiled, tv_client, movie_client)); if items.is_empty() { @@ -219,7 +245,7 @@ async fn build_plan( files: &[PathBuf], compiled: &ExpressionFormat, tv_client: Option, - movie_client: Option, + movie_client: Option, ) -> Vec { // Cache per (series_query, season), same as the GUI's own `run_fetch` - a whole // season's worth of files only needs one online search + episode-list fetch. @@ -277,16 +303,18 @@ async fn build_plan( } if let Some(client) = &movie_client { - if let Ok(info) = client.lookup_movie(&movie.title, movie.year).await { - bindings.insert("n".to_string(), info.title.clone()); - bindings.insert("movie name".to_string(), info.title.clone()); - if let Some(release_date) = &info.release_date { + if let Ok(MovieLookup::Found { title, release_date }) = + client.lookup_movie(&movie.title, movie.year).await + { + bindings.insert("n".to_string(), title.clone()); + bindings.insert("movie name".to_string(), title); + if let Some(release_date) = &release_date { bindings.insert("airdate".to_string(), release_date.clone()); // Only overwrite the locally-guessed year if Match didn't // already find one in the filename itself - same rule // RenamePanel.tsx's Fetch Data handler uses, since a - // filename-carried year is usually more precise than TMDB's - // release date for disambiguating remakes. + // filename-carried year is usually more precise than the + // online source's release date for disambiguating remakes. if movie.year.is_none() { let year: String = release_date.chars().take(4).collect(); bindings.insert("y".to_string(), year.clone()); @@ -492,4 +520,32 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + + #[test] + fn run_fails_cleanly_when_db_omdb_is_requested_without_a_saved_key() { + // Mirrors `run_fails_cleanly_when_db_tmdb_is_requested_without_a_saved_token` + // above, for the OMDb credential instead. + if settings::cli_omdb_api_key().is_some() { + eprintln!("skipping: a real OMDb API key is saved in this environment's config dir"); + return; + } + + let dir = temp_dir(); + let file = dir.join("Breaking.Bad.S05E14.mkv"); + std::fs::write(&file, b"hello").unwrap(); + + let code = run(vec![ + "-rename".to_string(), + "--format".to_string(), + "{n}".to_string(), + "--db".to_string(), + "omdb".to_string(), + file.to_string_lossy().to_string(), + ]); + + assert_eq!(code, 2); + assert!(file.exists(), "should fail before touching any file"); + + std::fs::remove_dir_all(&dir).ok(); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index da05153..76d1c7a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -101,8 +101,12 @@ pub fn run() { metadata::fetch_movie_metadata, settings::get_tmdb_token, settings::save_tmdb_token, + settings::get_omdb_api_key, + settings::save_omdb_api_key, settings::get_tv_metadata_source, settings::save_tv_metadata_source, + settings::get_movie_metadata_source, + settings::save_movie_metadata_source, history::list_rename_history, history::undo_rename_batch, presets::list_presets, diff --git a/src-tauri/src/metadata.rs b/src-tauri/src/metadata.rs index 106595e..090e221 100644 --- a/src-tauri/src/metadata.rs +++ b/src-tauri/src/metadata.rs @@ -4,18 +4,20 @@ //! Fetch Data needs a series name and season/episode to search with, which is exactly //! what Match already produces for free, offline. //! -//! TV lookups can go against either TMDB or TVmaze - whichever `settings::tv_metadata_ +//! TV lookups can go against TMDB, TVmaze, or OMDb - whichever `settings::tv_metadata_ //! source` currently has stored (see `AnyTvClient` below), defaulting to TMDB. Movie -//! lookups always go against TMDB regardless of that setting; TVmaze has no movie -//! catalog to pick between. +//! lookups can go against TMDB or OMDb - whichever `settings::movie_metadata_source` +//! currently has stored (see `AnyMovieClient` below), defaulting to TMDB; TVmaze has no +//! movie catalog, so it's never an option for the movie half of Fetch Data. +use aurora_curator_core::web::omdb::OmdbClient; use aurora_curator_core::web::tmdb::TmdbClient; use aurora_curator_core::web::tvmaze::TvmazeClient; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use tauri::AppHandle; -use crate::settings::{self, TvMetadataSource}; +use crate::settings::{self, MovieMetadataSource, TvMetadataSource}; /// One file's local guess, sent up from the UI to be resolved against TMDB. #[derive(Debug, Clone, Deserialize)] @@ -72,6 +74,7 @@ pub(crate) type SeasonLookup = Result<(String, Vec), String>; pub(crate) enum AnyTvClient { Tmdb(TmdbClient), Tvmaze(TvmazeClient), + Omdb(OmdbClient), } impl AnyTvClient { @@ -81,6 +84,7 @@ impl AnyTvClient { match self { AnyTvClient::Tmdb(_) => "TMDB", AnyTvClient::Tvmaze(_) => "TVmaze", + AnyTvClient::Omdb(_) => "OMDb", } } @@ -124,36 +128,113 @@ impl AnyTvClient { .collect(), )) } + AnyTvClient::Omdb(client) => { + let best = client + .search_tv_best_match(series_query) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("no {} match for \"{series_query}\"", self.source_name()))?; + let episodes = client + .season_episodes(&best.imdb_id, season) + .await + .map_err(|e| e.to_string())?; + Ok(( + best.name, + episodes + .into_iter() + .map(|e| LocalEpisode { + episode_number: e.episode_number, + title: e.title, + air_date: e.air_date, + }) + .collect(), + )) + } } } } +/// What a movie lookup found - `NotFound` is a normal, expected outcome (nothing +/// matched) and is kept distinct from `Err` (a real network/API failure) the same way +/// `TmdbError::NotFound`/`OmdbError::NotFound` are - `Err`'s `String` here is that +/// underlying error's `Display` text, already source-agnostic by the time it reaches a +/// caller. +pub(crate) enum MovieLookup { + Found { title: String, release_date: Option }, + NotFound, +} + +/// Either movie metadata client, picked once per `fetch_movie_metadata` call (or, for +/// the CLI, once per `-rename --db` invocation) based on the chosen source - mirrors +/// `AnyTvClient` above, but for the movie half of Fetch Data. `pub(crate)`: see +/// `LocalEpisode`'s doc comment above. +pub(crate) enum AnyMovieClient { + Tmdb(TmdbClient), + Omdb(OmdbClient), +} + +impl AnyMovieClient { + pub(crate) async fn lookup_movie(&self, title_query: &str, year: Option) -> Result { + match self { + AnyMovieClient::Tmdb(client) => match client.lookup_movie(title_query, year).await { + Ok(info) => Ok(MovieLookup::Found { + title: info.title, + release_date: info.release_date, + }), + Err(aurora_curator_core::web::tmdb::TmdbError::NotFound(_)) => Ok(MovieLookup::NotFound), + Err(error) => Err(error.to_string()), + }, + AnyMovieClient::Omdb(client) => match client.lookup_movie(title_query, year).await { + Ok(info) => Ok(MovieLookup::Found { + title: info.title, + release_date: info.release_date, + }), + Err(aurora_curator_core::web::omdb::OmdbError::NotFound(_)) => Ok(MovieLookup::NotFound), + Err(error) => Err(error.to_string()), + }, + } + } +} + +/// Builds the "every item failed the same way" result list a missing-credential guard +/// clause returns before any client is even constructed - shared by `fetch_metadata` +/// and `fetch_movie_metadata` below so the message and shape stay identical between the +/// TV and movie halves of Fetch Data. +fn failed_all(items: Vec, error: &str) -> Vec { + items + .into_iter() + .map(|item| FetchResult { + id: item.id, + outcome: FetchOutcome::Failed { error: error.to_string() }, + }) + .collect() +} + /// Resolves a batch of local guesses against real TV metadata. Runs off the main /// thread since it's doing real network I/O; not `blocking_*`-style Tauri API, so no /// deadlock risk like the dialog commands had - `spawn` here is just to keep it /// explicitly off the command-invocation path some Tauri internals run on. /// -/// Only the TMDB path needs a token; TVmaze needs none at all, so a batch can run -/// against TVmaze even with no TMDB token configured (movies, elsewhere in this file, -/// always need the TMDB token regardless of this setting). +/// TVmaze needs no credential at all, so a batch can run against it with nothing +/// configured; TMDB and OMDb each need their own saved credential before a batch +/// resolved against them will succeed (independently of whichever source movies are +/// using, elsewhere in this file). #[tauri::command] pub async fn fetch_metadata(app: AppHandle, items: Vec) -> Vec { let client = match settings::tv_metadata_source(&app) { TvMetadataSource::Tmdb => { let Some(token) = settings::tmdb_token(&app) else { - return items - .into_iter() - .map(|item| FetchResult { - id: item.id, - outcome: FetchOutcome::Failed { - error: "No TMDB API token configured — open Settings to add one.".to_string(), - }, - }) - .collect(); + return failed_all(items, "No TMDB API token configured — open Settings to add one."); }; AnyTvClient::Tmdb(TmdbClient::new(token)) } TvMetadataSource::Tvmaze => AnyTvClient::Tvmaze(TvmazeClient::new()), + TvMetadataSource::Omdb => { + let Some(key) = settings::omdb_api_key(&app) else { + return failed_all(items, "No OMDb API key configured — open Settings to add one."); + }; + AnyTvClient::Omdb(OmdbClient::new(key)) + } }; tauri::async_runtime::spawn(run_fetch(client, items)) @@ -226,41 +307,53 @@ pub struct MovieFetchResult { pub outcome: MovieFetchOutcome, } -/// Resolves a batch of local movie-title guesses against TMDB. Mirrors +/// Resolves a batch of local movie-title guesses against real movie metadata. Mirrors /// `fetch_metadata`, but each query is independent (no per-season caching the way TV /// episodes benefit from) since a movie search result already carries everything -/// needed in one request. +/// needed in one request (well, one search plus one detail request for OMDb - see +/// `omdb::OmdbClient::lookup_movie` - but that's an implementation detail this command +/// doesn't need to know about). #[tauri::command] pub async fn fetch_movie_metadata(app: AppHandle, items: Vec) -> Vec { - let Some(token) = settings::tmdb_token(&app) else { - return items - .into_iter() - .map(|item| MovieFetchResult { - id: item.id, - outcome: MovieFetchOutcome::Failed { - error: "No TMDB API token configured — open Settings to add one.".to_string(), - }, - }) - .collect(); + let client = match settings::movie_metadata_source(&app) { + MovieMetadataSource::Tmdb => { + let Some(token) = settings::tmdb_token(&app) else { + return failed_all_movies(items, "No TMDB API token configured — open Settings to add one."); + }; + AnyMovieClient::Tmdb(TmdbClient::new(token)) + } + MovieMetadataSource::Omdb => { + let Some(key) = settings::omdb_api_key(&app) else { + return failed_all_movies(items, "No OMDb API key configured — open Settings to add one."); + }; + AnyMovieClient::Omdb(OmdbClient::new(key)) + } }; - tauri::async_runtime::spawn(run_movie_fetch(token, items)) + tauri::async_runtime::spawn(run_movie_fetch(client, items)) .await .unwrap_or_default() } -async fn run_movie_fetch(token: String, items: Vec) -> Vec { - let client = TmdbClient::new(token); +/// Same idea as `failed_all` above, for the movie side's own result shape. +fn failed_all_movies(items: Vec, error: &str) -> Vec { + items + .into_iter() + .map(|item| MovieFetchResult { + id: item.id, + outcome: MovieFetchOutcome::Failed { error: error.to_string() }, + }) + .collect() +} + +async fn run_movie_fetch(client: AnyMovieClient, items: Vec) -> Vec { let mut results = Vec::with_capacity(items.len()); for item in items { let outcome = match client.lookup_movie(&item.title_query, item.year).await { - Ok(info) => MovieFetchOutcome::Found { - title: info.title, - release_date: info.release_date, - }, - Err(aurora_curator_core::web::tmdb::TmdbError::NotFound(_)) => MovieFetchOutcome::NotFound, - Err(error) => MovieFetchOutcome::Failed { error: error.to_string() }, + Ok(MovieLookup::Found { title, release_date }) => MovieFetchOutcome::Found { title, release_date }, + Ok(MovieLookup::NotFound) => MovieFetchOutcome::NotFound, + Err(error) => MovieFetchOutcome::Failed { error }, }; results.push(MovieFetchResult { id: item.id, outcome }); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 6289027..ae203bc 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,6 +1,6 @@ -//! Small persisted app settings - the TMDB API token, and (since the TVmaze datasource -//! was added) which TV metadata source Fetch Data should use ("Fetch Data" is the only -//! thing in the app that needs settings at all; everything else works offline). Stored +//! Small persisted app settings - the TMDB API token, the OMDb API key, and which TV +//! and movie metadata sources Fetch Data should use ("Fetch Data" is the only thing in +//! the app that needs settings at all; everything else works offline). Stored //! as plain JSON in the OS's standard per-app config directory, joined with the app's //! bundle identifier - `~/Library/Application Support/com.auroracurator.app/settings.json` //! on macOS, `~/.config/com.auroracurator.app/settings.json` on Linux, `%APPDATA%\ @@ -11,10 +11,10 @@ //! The GUI reaches that directory through a running Tauri `AppHandle` //! (`app.path().app_config_dir()`); the `-rename` CLI mode (`cli.rs`) never builds a //! Tauri app at all, so it needs the exact same directory computed a different way - -//! see [`cli_tmdb_token`] below, which replicates Tauri's own `dirs::config_dir() -//! .join(identifier)` computation directly (confirmed against Tauri 2.11's -//! `path/desktop.rs` source) so both code paths are guaranteed to read/write the same -//! file. [`APP_CONFIG_IDENTIFIER`] must be kept in sync with `tauri.conf.json`'s +//! see [`cli_tmdb_token`]/[`cli_omdb_api_key`] below, which replicate Tauri's own +//! `dirs::config_dir().join(identifier)` computation directly (confirmed against Tauri +//! 2.11's `path/desktop.rs` source) so both code paths are guaranteed to read/write the +//! same file. [`APP_CONFIG_IDENTIFIER`] must be kept in sync with `tauri.conf.json`'s //! `identifier` field by hand - there's no way to share a constant with the JSON config. use serde::{Deserialize, Serialize}; @@ -25,14 +25,15 @@ use tauri::{AppHandle, Manager}; /// Must match `tauri.conf.json`'s `identifier` field exactly - see the module doc. const APP_CONFIG_IDENTIFIER: &str = "com.auroracurator.app"; -/// Which online source Fetch Data resolves TV episodes against. Movies always use -/// TMDB regardless of this setting - TVmaze doesn't have a movie catalog at all, so -/// there's nothing to pick between for the movie half of Fetch Data. +/// Which online source Fetch Data resolves TV episodes against. TVmaze needs no API +/// key at all; TMDB and OMDb each need their own saved credential (see +/// [`tmdb_token`]/[`omdb_api_key`]) before a batch resolved against them will succeed. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum TvMetadataSource { Tmdb, Tvmaze, + Omdb, } impl Default for TvMetadataSource { @@ -41,12 +42,32 @@ impl Default for TvMetadataSource { } } +/// Which online source Fetch Data resolves movies against. Unlike TV, there's no +/// zero-setup option here (TVmaze has no movie catalog) - both choices need their own +/// saved credential. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum MovieMetadataSource { + Tmdb, + Omdb, +} + +impl Default for MovieMetadataSource { + fn default() -> Self { + MovieMetadataSource::Tmdb + } +} + #[derive(Debug, Default, Serialize, Deserialize)] struct StoredSettings { #[serde(default)] tmdb_token: Option, #[serde(default)] + omdb_api_key: Option, + #[serde(default)] tv_metadata_source: TvMetadataSource, + #[serde(default)] + movie_metadata_source: MovieMetadataSource, } fn settings_path(app: &AppHandle) -> Result { @@ -106,6 +127,36 @@ pub fn save_tmdb_token(app: AppHandle, token: String) -> Result<(), String> { save(&app, &settings) } +/// Returns the stored OMDb API key, or `None` if nothing (or only blank text) is +/// saved. Mirrors [`tmdb_token`] exactly - OMDb calls its credential a "key" rather +/// than a "token", but it's the same kind of thing (a plain saved secret, entered once +/// through Fetch Data Settings, never handled by anything but the user and this app). +pub fn omdb_api_key(app: &AppHandle) -> Option { + load(app).omdb_api_key.filter(|t| !t.trim().is_empty()) +} + +/// Same as [`omdb_api_key`], but for the `-rename` CLI path - mirrors [`cli_tmdb_token`] +/// exactly, see its doc comment for why this can't just ask an `AppHandle`. +pub fn cli_omdb_api_key() -> Option { + let dir = dirs::config_dir()?.join(APP_CONFIG_IDENTIFIER); + let contents = fs::read_to_string(dir.join("settings.json")).ok()?; + let settings: StoredSettings = serde_json::from_str(&contents).ok()?; + settings.omdb_api_key.filter(|t| !t.trim().is_empty()) +} + +#[tauri::command] +pub fn get_omdb_api_key(app: AppHandle) -> Option { + omdb_api_key(&app) +} + +#[tauri::command] +pub fn save_omdb_api_key(app: AppHandle, key: String) -> Result<(), String> { + let mut settings = load(&app); + let trimmed = key.trim().to_string(); + settings.omdb_api_key = if trimmed.is_empty() { None } else { Some(trimmed) }; + save(&app, &settings) +} + /// Returns the stored TV metadata source (defaults to TMDB if nothing's been chosen). pub fn tv_metadata_source(app: &AppHandle) -> TvMetadataSource { load(app).tv_metadata_source @@ -123,6 +174,24 @@ pub fn save_tv_metadata_source(app: AppHandle, source: TvMetadataSource) -> Resu save(&app, &settings) } +/// Returns the stored movie metadata source (defaults to TMDB if nothing's been +/// chosen - movies were TMDB-only before OMDb added a second option). +pub fn movie_metadata_source(app: &AppHandle) -> MovieMetadataSource { + load(app).movie_metadata_source +} + +#[tauri::command] +pub fn get_movie_metadata_source(app: AppHandle) -> MovieMetadataSource { + movie_metadata_source(&app) +} + +#[tauri::command] +pub fn save_movie_metadata_source(app: AppHandle, source: MovieMetadataSource) -> Result<(), String> { + let mut settings = load(&app); + settings.movie_metadata_source = source; + save(&app, &settings) +} + #[cfg(test)] mod tests { use super::*; @@ -134,11 +203,19 @@ mod tests { #[test] fn tv_metadata_source_serializes_as_lowercase_camel_case() { - // The frontend sends/receives this as a plain string ("tmdb"/"tvmaze") - pin - // the exact serde output so a future refactor can't silently change the wire - // format out from under the TS side without a test catching it. + // The frontend sends/receives this as a plain string ("tmdb"/"tvmaze"/"omdb") - + // pin the exact serde output so a future refactor can't silently change the + // wire format out from under the TS side without a test catching it. assert_eq!(serde_json::to_string(&TvMetadataSource::Tmdb).unwrap(), "\"tmdb\""); assert_eq!(serde_json::to_string(&TvMetadataSource::Tvmaze).unwrap(), "\"tvmaze\""); + assert_eq!(serde_json::to_string(&TvMetadataSource::Omdb).unwrap(), "\"omdb\""); + } + + #[test] + fn movie_metadata_source_defaults_to_tmdb_and_serializes_as_lowercase() { + assert_eq!(MovieMetadataSource::default(), MovieMetadataSource::Tmdb); + assert_eq!(serde_json::to_string(&MovieMetadataSource::Tmdb).unwrap(), "\"tmdb\""); + assert_eq!(serde_json::to_string(&MovieMetadataSource::Omdb).unwrap(), "\"omdb\""); } #[test] @@ -169,4 +246,16 @@ mod tests { assert_eq!(parsed.tv_metadata_source, TvMetadataSource::Tmdb); assert_eq!(parsed.tmdb_token, Some("abc123".to_string())); } + + #[test] + fn stored_settings_without_omdb_fields_still_deserializes() { + // Same schema-evolution guarantee as the TV-metadata-source test above, for the + // two fields OMDb support added: an old settings.json with neither + // `omdb_api_key` nor `movie_metadata_source` present must still load cleanly. + let old_json = r#"{"tmdb_token":"abc123","tv_metadata_source":"tvmaze"}"#; + let parsed: StoredSettings = serde_json::from_str(old_json).unwrap(); + assert_eq!(parsed.omdb_api_key, None); + assert_eq!(parsed.movie_metadata_source, MovieMetadataSource::Tmdb); + assert_eq!(parsed.tv_metadata_source, TvMetadataSource::Tvmaze); + } } diff --git a/src/components/RenamePanel.css b/src/components/RenamePanel.css index 1a55c9c..042b214 100644 --- a/src/components/RenamePanel.css +++ b/src/components/RenamePanel.css @@ -530,6 +530,14 @@ font-size: 12.5px; } +.modal-inline-remove { + width: auto; + align-self: flex-start; + margin: 6px 0 4px; + padding: 4px 12px; + font-size: 11.5px; +} + .history-batch-list { gap: 6px; } diff --git a/src/components/RenamePanel.tsx b/src/components/RenamePanel.tsx index a8fc2a4..165b71e 100644 --- a/src/components/RenamePanel.tsx +++ b/src/components/RenamePanel.tsx @@ -106,9 +106,15 @@ type ConflictPolicy = "skip" | "autoNumber" | "override"; // Mirrors aurora-curator-core's `TvMetadataSource` - which online source Fetch Data // resolves TV episodes against. "tmdb" is the default (matches Aurora Curator's original and -// only source before TVmaze existed); "tvmaze" needs no API token at all. Movies always -// use TMDB regardless of this setting - TVmaze has no movie catalog to pick between. -type TvMetadataSource = "tmdb" | "tvmaze"; +// only source before TVmaze existed); "tvmaze" needs no API token at all; "omdb" needs +// its own separate API key (see MovieMetadataSource below - the two settings are +// independent, since OMDb covers both TV and movies but TVmaze only covers TV). +type TvMetadataSource = "tmdb" | "tvmaze" | "omdb"; + +// Mirrors aurora-curator-core's `MovieMetadataSource` - which online source Fetch Data +// resolves movies against. Unlike TV there's no zero-setup option (TVmaze has no movie +// catalog) - both choices need their own saved credential. +type MovieMetadataSource = "tmdb" | "omdb"; // Sent to `fetch_metadata`: one file's local guess (from Match) to resolve against // real online TV metadata (TMDB or TVmaze, per the stored TvMetadataSource). Needs a @@ -324,12 +330,29 @@ const CONFLICT_POLICY_LABELS: Record = { tmdb: { button: "TMDB", - description: "Uses TheMovieDB for TV episode titles and air dates — needs the API token below.", + description: "Uses TheMovieDB for TV episode titles and air dates — needs the TMDB API token below.", }, tvmaze: { button: "TVmaze", description: "Uses TVmaze for TV episode titles and air dates — free, no API token needed at all.", }, + omdb: { + button: "OMDb", + description: "Uses OMDb for TV episode titles and air dates — needs the OMDb API key below.", + }, +}; + +// Copy for each MovieMetadataSource, used in the Fetch Data Settings modal's own +// movie-source segmented selector and its description note. Mirrors TV_SOURCE_LABELS. +const MOVIE_SOURCE_LABELS: Record = { + tmdb: { + button: "TMDB", + description: "Uses TheMovieDB for movie titles and release dates — needs the TMDB API token below.", + }, + omdb: { + button: "OMDb", + description: "Uses OMDb for movie titles and release dates — needs the OMDb API key below.", + }, }; function realFileToItem(entry: FileEntry): Item { @@ -425,12 +448,19 @@ export default function RenamePanel() { // nothing saved, `true` = a token is on file (the value itself never comes back to // the frontend after saving - see Settings modal below). const [hasTmdbToken, setHasTmdbToken] = useState(null); + // Same idea as hasTmdbToken, but for the OMDb API key - the two credentials are + // entirely independent, since either source can be picked for TV, movies, or both. + const [hasOmdbKey, setHasOmdbKey] = useState(null); // Which online source Fetch Data uses for TV episodes - persisted on disk (see - // `src-tauri/src/settings.rs`), defaults to "tmdb" until loaded. Movies always use - // TMDB regardless of this, so it only ever gates the TV half of Fetch Data. + // `src-tauri/src/settings.rs`), defaults to "tmdb" until loaded. const [tvSource, setTvSource] = useState("tmdb"); + // Which online source Fetch Data uses for movies - persisted separately from + // tvSource (see `src-tauri/src/settings.rs`'s MovieMetadataSource), defaults to + // "tmdb" until loaded. TVmaze isn't an option here - it has no movie catalog. + const [movieSource, setMovieSource] = useState("tmdb"); const [settingsOpen, setSettingsOpen] = useState(false); const [tmdbTokenInput, setTmdbTokenInput] = useState(""); + const [omdbKeyInput, setOmdbKeyInput] = useState(""); const [savingSettings, setSavingSettings] = useState(false); const [fetching, setFetching] = useState(false); // Rename history / undo. `null` batches = not loaded yet (also used to show a @@ -462,12 +492,24 @@ export default function RenamePanel() { .catch(() => setHasTmdbToken(false)); }, []); + useEffect(() => { + invoke("get_omdb_api_key") + .then((key) => setHasOmdbKey(!!key)) + .catch(() => setHasOmdbKey(false)); + }, []); + useEffect(() => { invoke("get_tv_metadata_source") .then(setTvSource) .catch(() => setTvSource("tmdb")); }, []); + useEffect(() => { + invoke("get_movie_metadata_source") + .then(setMovieSource) + .catch(() => setMovieSource("tmdb")); + }, []); + // Refs so the drag-and-drop listener (registered once on mount) always sees the // latest expression/items instead of the stale values from its first render. const expressionRef = useRef(expression); @@ -907,27 +949,40 @@ export default function RenamePanel() { const openSettings = () => { setTmdbTokenInput(""); + setOmdbKeyInput(""); setSettingsOpen(true); }; - const saveTmdbToken = async () => { - const trimmed = tmdbTokenInput.trim(); - if (!trimmed) { + // Saves whichever credential field(s) the user actually typed into, then closes the + // modal - a single Save button covers both the TMDB token and the OMDb key so + // filling in one (or both) at once only takes one click, mirroring how the two + // segmented source pickers above it already sit side by side in the same modal. + const saveCredentials = async () => { + const trimmedTmdb = tmdbTokenInput.trim(); + const trimmedOmdb = omdbKeyInput.trim(); + if (!trimmedTmdb && !trimmedOmdb) { // Nothing typed - just close, leaving whatever was already saved untouched. setSettingsOpen(false); return; } setSavingSettings(true); try { - await invoke("save_tmdb_token", { token: trimmed }); - setHasTmdbToken(true); + if (trimmedTmdb) { + await invoke("save_tmdb_token", { token: trimmedTmdb }); + setHasTmdbToken(true); + } + if (trimmedOmdb) { + await invoke("save_omdb_api_key", { key: trimmedOmdb }); + setHasOmdbKey(true); + } setTmdbTokenInput(""); + setOmdbKeyInput(""); setSettingsOpen(false); - setStatus("TMDB token saved."); - window.clearTimeout((saveTmdbToken as any)._t); - (saveTmdbToken as any)._t = window.setTimeout(() => setStatus(null), 3000); + setStatus("Fetch Data settings saved."); + window.clearTimeout((saveCredentials as any)._t); + (saveCredentials as any)._t = window.setTimeout(() => setStatus(null), 3000); } catch (err) { - setStatus(`Couldn't save the token: ${err}`); + setStatus(`Couldn't save: ${err}`); } finally { setSavingSettings(false); } @@ -964,12 +1019,42 @@ export default function RenamePanel() { } }; + // Same idea as saveTvSource, for the movie-source picker. + const saveMovieSource = async (source: MovieMetadataSource) => { + setMovieSource(source); + try { + await invoke("save_movie_metadata_source", { source }); + } catch (err) { + setStatus(`Couldn't save the movie source setting: ${err}`); + window.clearTimeout((saveMovieSource as any)._t); + (saveMovieSource as any)._t = window.setTimeout(() => setStatus(null), 4000); + } + }; + + const clearOmdbApiKey = async () => { + setSavingSettings(true); + try { + await invoke("save_omdb_api_key", { key: "" }); + setHasOmdbKey(false); + setOmdbKeyInput(""); + setStatus("OMDb API key removed."); + window.clearTimeout((clearOmdbApiKey as any)._t); + (clearOmdbApiKey as any)._t = window.setTimeout(() => setStatus(null), 3000); + } catch (err) { + setStatus(`Couldn't remove the key: ${err}`); + } finally { + setSavingSettings(false); + } + }; + // "Fetch Data": resolve each already-Matched file's local guess against real online // metadata. Requires Match to have already populated either a series name + season/ // episode (TV) or a movie title (movie) - that's the search query - so this is // deliberately layered on top of local matching, not a replacement for it. TV items - // resolve against whichever source `tvSource` currently picks (TMDB or TVmaze); movie - // items always resolve against TMDB. Both run via separate Tauri commands, + // resolve against whichever source `tvSource` currently picks (TMDB, TVmaze, or + // OMDb); movie items resolve against whichever source `movieSource` picks (TMDB or + // OMDb) - the two settings are independent, so a batch mixing TV and movies can even + // use different sources for each half. Both run via separate Tauri commands, // concurrently, and merge back into a single set of counts/status message. const runFetch = async () => { const tvTargets = items.filter( @@ -985,12 +1070,19 @@ export default function RenamePanel() { return; } - // TVmaze needs no token at all, so a TMDB token is only actually required when - // there are movie targets (always TMDB) or TV targets while TMDB is the chosen - // source - a TV-only batch on TVmaze can run with no TMDB token configured at all. - const needsTmdbToken = movieTargets.length > 0 || (tvTargets.length > 0 && tvSource === "tmdb"); - if (needsTmdbToken && hasTmdbToken === false) { - setStatus("Add a free TMDB API token in Settings first, then Fetch Data will work."); + // TVmaze needs no credential at all, so TMDB/OMDb are only actually required when + // this batch's TV or movie targets are actually pointed at that source - a TV-only + // batch on TVmaze can run with neither credential configured at all. + const needsTmdbToken = + (movieTargets.length > 0 && movieSource === "tmdb") || (tvTargets.length > 0 && tvSource === "tmdb"); + const needsOmdbKey = + (movieTargets.length > 0 && movieSource === "omdb") || (tvTargets.length > 0 && tvSource === "omdb"); + if ((needsTmdbToken && hasTmdbToken === false) || (needsOmdbKey && hasOmdbKey === false)) { + const missing = [ + needsTmdbToken && hasTmdbToken === false ? "a free TMDB API token" : null, + needsOmdbKey && hasOmdbKey === false ? "a free OMDb API key" : null, + ].filter((m): m is string => m !== null); + setStatus(`Add ${missing.join(" and ")} in Settings first, then Fetch Data will work.`); setSettingsOpen(true); window.clearTimeout((runFetch as any)._t); (runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000); @@ -1363,10 +1455,10 @@ export default function RenamePanel() { needs a network call, since it's all parsed straight from the filename itself (Aurora Curator has no video/audio file prober, so a tag that isn't in the filename text just won't be available). Fetch Data resolves the season/episode or movie guess against real online metadata for the real title, episode title, and - air/release date. TV episodes can come from either TMDB or TVmaze — pick which in{" "} - Fetch Data Settings (TVmaze is free and needs no token at all); movies always use TMDB - and need a free API token set there first. A batch can - freely mix TV and movie files — each file only fills in the fields that apply to it. FileBot's own short + air/release date. TV episodes can come from TMDB, TVmaze, or OMDb, and movies from TMDB or OMDb — pick + each independently in Fetch Data Settings (TVmaze is free and needs no credential at + all; TMDB and OMDb each need their own free API credential set there first). A batch can + freely mix TV and movie files, even against different sources — each file only fills in the fields that apply to it. FileBot's own short field names ({"{n}"}, {"{s00e00}"}, {"{t}"}, {"{y}"}, {"{vf}"}, {"{vc}"}, {"{ac}"}, {"{group}"}, etc.) still work too, side by side with the names above. @@ -1486,7 +1578,7 @@ export default function RenamePanel() {

Fetch Data Settings

TV episode source
- {(["tmdb", "tvmaze"] as const).map((s) => ( + {(["tmdb", "tvmaze", "omdb"] as const).map((s) => ( + ))} +

- Movies always resolve against{" "} + {MOVIE_SOURCE_LABELS[movieSource].description} TVmaze isn't an option here — it has no movie catalog. +

+ +

+ Get a free API Read Access Token from your{" "} - TheMovieDB + TMDB {" "} - regardless of the TV source above — TVmaze has no movie catalog. Get a free{" "} - API Read Access Token from your TMDB account under Settings → API, then paste it below. - The token is stored locally on this machine and never leaves it except to call TMDB directly. + account under Settings → API, and/or a free API key from{" "} + + OMDb + + , then paste whichever one(s) you need below. Each credential is stored locally on this machine and + never leaves it except to call that source directly.

setTmdbTokenInput(e.target.value)} placeholder={hasTmdbToken ? "•••••••••• (already saved — enter a new token to replace it)" : "Paste your TMDB token here"} /> -
- {hasTmdbToken ? ( - - ) : ( - - )} -
- - -
+ {hasTmdbToken && ( + + )} + + setOmdbKeyInput(e.target.value)} + placeholder={hasOmdbKey ? "•••••••••• (already saved — enter a new key to replace it)" : "Paste your OMDb key here"} + /> + {hasOmdbKey && ( + + )} +
+ +