From 5763c6313b1097e00ffb385ff699e6550318518f Mon Sep 17 00:00:00 2001 From: Renamr Scaffold Date: Sat, 29 Aug 2026 10:44:12 +0000 Subject: [PATCH] Add TVmaze as a second, user-selectable TV metadata source for Fetch Data TV episodes can now resolve against either TMDB (default) or TVmaze - picked via a segmented source picker in the renamed "Fetch Data Settings" modal. TVmaze is free and needs no API token at all; movies still always resolve against TMDB regardless of the choice, since TVmaze has no movie catalog. - New TvmazeClient (crates/aurora-curator-core/src/web/tvmaze.rs): search + season/episode lookup against api.tvmaze.com, filtering out unnumbered specials rather than panicking on them (TVmaze returns every season's episodes in one flat list, unlike TMDB's per-season endpoint). - New TvMetadataSource setting (settings.rs), persisted alongside the TMDB token, defaulting to Tmdb for schema-evolution safety. - metadata.rs now branches TV fetches through an AnyTvClient wrapper so the existing per-(series, season) caching loop doesn't care which source produced a result; the movie-fetch path is untouched. - Frontend: TMDB/TVmaze picker in Fetch Data Settings, persisted on change; Fetch Data's token gating now only requires a TMDB token when it's actually needed (movies always, TV only when TMDB is the chosen source) instead of unconditionally blocking on it. cargo test --workspace: 88/88 passing (81 + 7 new TVmaze + 3 new settings). npm run build passing. Playwright verification: 18/18 checks (picker renders and persists across reload, TV Fetch Data succeeds on TVmaze with zero TMDB token configured, movies still correctly demand a token even with TVmaze selected, switching back to TMDB still works end-to-end). --- README.md | 88 +++-- crates/aurora-curator-core/src/web/mod.rs | 9 +- crates/aurora-curator-core/src/web/tvmaze.rs | 396 +++++++++++++++++++ src-tauri/src/lib.rs | 2 + src-tauri/src/metadata.rs | 148 +++++-- src-tauri/src/settings.rs | 72 +++- src/components/RenamePanel.tsx | 110 ++++-- 7 files changed, 729 insertions(+), 96 deletions(-) create mode 100644 crates/aurora-curator-core/src/web/tvmaze.rs diff --git a/README.md b/README.md index 6ef77d6..9812426 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,13 @@ fork), built from scratch as its own project: new stack, new architecture, new b **Status: early scaffold, but the core loop works end to end.** The naming-expression engine is ported and tested, the faithfully-recreated two-pane UI is wired to real native file/folder loading and drag-and-drop, local filename-based season/episode *and -movie title/year* detection ("Match") is working, real online metadata lookups against -TMDB ("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 TMDB's top-ranked hit — and the actual on-disk operation is wired up (with a +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 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/ @@ -129,38 +132,43 @@ 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), a TMDB metadata client (`web/tmdb.rs` — TV - search + episode lookup - and movie search + lookup, both disambiguated via `similarity/`, - tested against a mocked HTTP server via `mockito`), the on-disk + `EpisodeMetrics` port), two web metadata clients — `web/tmdb.rs` + (TV search + episode lookup and movie search + lookup) and + `web/tvmaze.rs` (TV search + episode lookup only — TVmaze has no + movie catalog, and no API key/auth at all), both disambiguated via + `similarity/` and tested against a mocked HTTP server via + `mockito`, the on-disk rename engine (`rename/` — Move/Copy/Symlink actions, Skip/Auto-number/Override conflict policies, can also organize into (auto-created) subfolders when the naming scheme includes `/`) — and a rename history/undo log (`history/`, a partial `HistoryDialog` port, undo-aware of which action produced each batch). Not yet ported: full media metadata - bindings, other web datasource clients (TVDB/AniDB/etc). + bindings, other web datasource clients (TheTVDB/AniDB/OMDb/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, TMDB metadata fetch — `metadata.rs` for TV episodes, - plus a movie-fetch command alongside it — executing a rename + 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 plan, native file/folder dialogs, drag-and-drop expansion), plus - `settings.rs` (TMDB token), `history.rs` (rename history/undo + `settings.rs` (TMDB token, and which TV metadata source Fetch Data + uses), `history.rs` (rename history/undo log), and `presets.rs` (up to 9 saved naming schemes) — all three stored as JSON in the OS app-config directory — app metadata/icons/window config (tauri.conf.json). src/ React + TypeScript UI (Vite). Faithful recreation of the original two-pane rename screen (Aurora theme) wired to real file loading, drag-and-drop, live naming-scheme preview, local TV/movie - matching, TMDB fetch for both (behind a Settings modal for the - API token), a confirm-before-touching-disk modal for the actual + matching, metadata fetch for both (behind a Fetch Data Settings + modal holding the TMDB/TVmaze source picker and the API token), 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 saved naming schemes. -scripts/gen_icons.py Regenerates src-tauri/icons/ (placeholder branding — swap the - colors/glyph in the script, or replace the icons directly, to - change the look). +scripts/gen_icons.py Regenerates src-tauri/icons/ from the real logo mark + (`scripts/logo/icon-source.png`, cropped from the supplied + lockup) — replace that source image to change the look. ``` ## Building @@ -183,24 +191,35 @@ npm run tauri build ``` Verified in the environment this was built in: `cargo test -p aurora-curator-core` (all -passing, including the TMDB 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 +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. -### Setting up TMDB (for "Fetch Data") +### Setting up metadata sources (for "Fetch Data") Everything else in the app works fully offline; "Fetch Data" is the one feature that -needs a (free) [TMDB](https://www.themoviedb.org/) account: +talks to the network, and it's the only one that needs any setup at all — and even that +setup is optional for TV. -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, click the key icon ("TMDB Settings") next to Fetch Data and paste the - token in. It's saved locally in the OS app-config directory and only ever sent to - `api.themoviedb.org`. +TV episodes can resolve against either of two sources, picked in the app (click the key +icon, "Fetch Data Settings", next to Fetch Data): + +- **[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: + 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`. + +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. ## Porting roadmap (not yet done) @@ -240,13 +259,20 @@ 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. TMDB is done for both TV and movies + AniDB, OMDb, TVmaze, OpenSubtitles, etc. Two 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 — - both wired to "Fetch Data", disambiguating multi-result searches via the - similarity-scoring pass described in item 3 above rather than blindly trusting - TMDB's top-ranked hit. All other sources (TheTVDB, AniDB, OMDb, TVmaze, + and 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 + 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. 6. **Rename engine + CLI** — `net.filebot.cli.CmdlineOperations` (1235 lines) and `ArgumentBean` (415 lines, the full flag surface: `-rename`, `--db`, `--format`, diff --git a/crates/aurora-curator-core/src/web/mod.rs b/crates/aurora-curator-core/src/web/mod.rs index b3b8dd5..1c0442f 100644 --- a/crates/aurora-curator-core/src/web/mod.rs +++ b/crates/aurora-curator-core/src/web/mod.rs @@ -1,5 +1,8 @@ -//! Online metadata clients ("Fetch Data"). Currently just TMDB; 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 and has a simple, well-documented API. +//! 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). pub mod tmdb; +pub mod tvmaze; diff --git a/crates/aurora-curator-core/src/web/tvmaze.rs b/crates/aurora-curator-core/src/web/tvmaze.rs new file mode 100644 index 0000000..28ec98a --- /dev/null +++ b/crates/aurora-curator-core/src/web/tvmaze.rs @@ -0,0 +1,396 @@ +//! A small client for [TVmaze](https://www.tvmaze.com/)'s free REST API: search for a +//! TV show by name, then look up a specific episode's real title/air date. +//! +//! TV-only (TVmaze doesn't have a movie catalog at all, unlike TMDB) and, unlike TMDB, +//! needs **no API key** - every endpoint used here is fully public. That makes it a +//! zero-setup second TV metadata source: a user who doesn't want to register for a TMDB +//! token, or whose show just isn't on TMDB, can pick this instead in Fetch Data +//! Settings. See `crate::web::tmdb` for the TMDB client this mirrors the shape of. + +use serde::Deserialize; +use thiserror::Error; + +use crate::similarity::best_title_match; + +const DEFAULT_BASE_URL: &str = "https://api.tvmaze.com"; + +/// Same threshold TMDB's client uses (see `tmdb::NAME_MATCH_THRESHOLD`) - kept +/// identical so switching sources doesn't change how forgiving/strict name matching +/// feels to the user. +const NAME_MATCH_THRESHOLD: f64 = 0.35; + +#[derive(Debug, Error)] +pub enum TvmazeError { + #[error("network error talking to TVmaze: {0}")] + Network(String), + #[error("TVmaze API error (status {status}): {message}")] + Api { status: u16, message: String }, + #[error("no TVmaze match found for \"{0}\"")] + NotFound(String), +} + +/// One TVmaze search hit's show data. Only the fields Aurora Curator actually uses are +/// kept - TVmaze's `/search/shows` wraps each show in a `{score, show: {...}}` envelope +/// (the score is TVmaze's own relevance ranking, not used here - `best_title_match` +/// re-ranks by name similarity instead, same as the TMDB client does). +#[derive(Debug, Clone, Deserialize)] +pub struct TvResult { + pub id: u64, + pub name: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct SearchHit { + show: TvResult, +} + +#[derive(Debug, Clone, Deserialize)] +struct EpisodeResult { + season: i32, + number: Option, + name: String, + #[serde(default)] + airdate: Option, +} + +/// Real metadata for one episode, resolved from TVmaze - same shape as +/// `tmdb::EpisodeInfo` so the Tauri glue can treat either 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`'s field names so both clients' results map onto the same local +/// shape in the Tauri glue. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SeasonEpisode { + pub episode_number: i32, + pub title: String, + pub air_date: Option, +} + +pub struct TvmazeClient { + http: reqwest::Client, + base_url: String, +} + +impl Default for TvmazeClient { + fn default() -> Self { + Self::new() + } +} + +impl TvmazeClient { + pub fn new() -> Self { + Self { + http: reqwest::Client::new(), + 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 TVmaze API. + pub fn with_base_url(mut self, base_url: impl Into) -> Self { + self.base_url = base_url.into(); + self + } + + async fn get_json Deserialize<'de>>( + &self, + path: &str, + query: &[(&str, &str)], + ) -> Result { + let url = format!("{}{path}", self.base_url); + let response = self + .http + .get(&url) + .query(query) + .send() + .await + .map_err(|e| TvmazeError::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(TvmazeError::Api { status, message }); + } + + response + .json::() + .await + .map_err(|e| TvmazeError::Network(e.to_string())) + } + + /// Searches for a TV show by name. Results come back TVmaze-ranked by its own + /// relevance score; callers wanting "the best guess" should use + /// `search_tv_best_match` instead, same convention as the TMDB client. + pub async fn search_tv(&self, query: &str) -> Result, TvmazeError> { + let hits: Vec = self.get_json("/search/shows", &[("q", query)]).await?; + Ok(hits.into_iter().map(|h| h.show).collect()) + } + + /// Fetches EVERY episode of a show (all seasons) in a single request - TVmaze's + /// `/shows/{id}/episodes` endpoint doesn't take a season filter, unlike TMDB's + /// per-season endpoint, so this fetches once and filters locally. Prefer calling + /// this once and reusing the result over calling it per-season/per-episode. + async fn all_episodes(&self, series_id: u64) -> Result, TvmazeError> { + let path = format!("/shows/{series_id}/episodes"); + self.get_json(&path, &[]).await + } + + /// Fetches one season's episodes for a known TVmaze show id - mirrors + /// `TmdbClient::season_episodes`'s signature/return shape even though the + /// underlying TVmaze call fetches (and then filters) every season at once. + pub async fn season_episodes(&self, series_id: u64, season: i32) -> Result, TvmazeError> { + let episodes = self.all_episodes(series_id).await?; + Ok(episodes + .into_iter() + .filter(|e| e.season == season && e.number.is_some()) + .map(|e| SeasonEpisode { + episode_number: e.number.expect("filtered to Some above"), + title: e.name, + air_date: e.airdate, + }) + .collect()) + } + + /// Searches for a TV show by name and returns the single best-matching result (see + /// `similarity::best_title_match`), or `None` if nothing in the results is a + /// plausible match at all. Mirrors `TmdbClient::search_tv_best_match`. + pub async fn search_tv_best_match(&self, query: &str) -> Result, TvmazeError> { + let results = self.search_tv(query).await?; + Ok(best_title_match(query, &results, |r| r.name.as_str(), NAME_MATCH_THRESHOLD).cloned()) + } + + /// Convenience: search for the best-matching show by name, then fetch a specific + /// episode. Same disambiguation approach as `TmdbClient::lookup_episode` - picks + /// whichever result's name is textually closest to `series_query` rather than + /// trusting TVmaze's own top-ranked result, 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(|| TvmazeError::NotFound(series_query.to_string()))?; + + let episodes = self.season_episodes(best.id, season).await?; + let found = episodes + .into_iter() + .find(|e| e.episode_number == episode) + .ok_or_else(|| TvmazeError::NotFound(format!("S{season:02}E{episode:02}")))?; + + Ok(EpisodeInfo { + series_name: best.name, + episode_title: found.title, + air_date: found.air_date, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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", "/search/shows") + .match_query(mockito::Matcher::UrlEncoded("q".into(), "Girls".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"[{"score":10.5,"show":{"id":139,"name":"Girls"}}]"#) + .create_async() + .await; + + // TVmaze returns every season's episodes in one call - the mock reflects that, + // with episodes from two different seasons mixed together to prove filtering + // by season actually happens rather than just taking whatever's returned. + let _episodes_mock = server + .mock("GET", "/shows/139/episodes") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"[ + {"season":1,"number":1,"name":"Pilot","airdate":"2012-04-15"}, + {"season":2,"number":1,"name":"It's About Time","airdate":"2013-01-13"}, + {"season":2,"number":2,"name":"I Get Ideas","airdate":"2013-01-20"} + ]"#, + ) + .create_async() + .await; + + let client = TvmazeClient::new().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", "/search/shows") + .match_query(mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body("[]") + .create_async() + .await; + + let client = TvmazeClient::new().with_base_url(server.url()); + let result = client.lookup_episode("Some Nonexistent Show", 1, 1).await; + + assert!(matches!(result, Err(TvmazeError::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", "/search/shows") + .match_query(mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"[{"score":1.0,"show":{"id":42,"name":"Some Show"}}]"#) + .create_async() + .await; + + let _episodes_mock = server + .mock("GET", "/shows/42/episodes") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"[{"season":1,"number":1,"name":"Pilot","airdate":"2020-01-01"}]"#) + .create_async() + .await; + + let client = TvmazeClient::new().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(TvmazeError::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", "/search/shows") + .match_query(mockito::Matcher::UrlEncoded("q".into(), "Chuck".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"[{"score":20.0,"show":{"id":1,"name":"Chucky"}},{"score":5.0,"show":{"id":2,"name":"Chuck"}}]"#) + .create_async() + .await; + + let _episodes_mock = server + .mock("GET", "/shows/2/episodes") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"[{"season":1,"number":1,"name":"Chuck Versus the Intersect","airdate":"2007-09-24"}]"#) + .create_async() + .await; + + let client = TvmazeClient::new().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", "/search/shows") + .match_query(mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"[{"score":1.0,"show":{"id":9,"name":"Totally Unrelated Program"}}]"#) + .create_async() + .await; + + let client = TvmazeClient::new().with_base_url(server.url()); + let result = client.lookup_episode("Zzyzx Nonexistent Show", 1, 1).await; + + assert!(matches!(result, Err(TvmazeError::NotFound(_)))); + } + + #[tokio::test] + async fn episodes_with_no_episode_number_are_skipped_rather_than_panicking() { + // TVmaze occasionally has "special"/non-numbered entries (e.g. season 0 + // specials, or episodes with number:null) mixed in with regular episodes - + // make sure filtering by season doesn't choke on one, since a naive `.unwrap()` + // on `number` would panic on real data. + let mut server = mockito::Server::new_async().await; + + let _search_mock = server + .mock("GET", "/search/shows") + .match_query(mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"[{"score":1.0,"show":{"id":7,"name":"Some Show"}}]"#) + .create_async() + .await; + + let _episodes_mock = server + .mock("GET", "/shows/7/episodes") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"[ + {"season":1,"number":null,"name":"Uncategorized Special","airdate":null}, + {"season":1,"number":1,"name":"Pilot","airdate":"2021-01-01"} + ]"#, + ) + .create_async() + .await; + + let client = TvmazeClient::new().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 surfaces_a_server_error_as_an_api_error() { + let mut server = mockito::Server::new_async().await; + + let _mock = server + .mock("GET", "/search/shows") + .match_query(mockito::Matcher::Any) + .with_status(503) + .with_header("content-type", "text/plain") + .with_body("service unavailable") + .create_async() + .await; + + let client = TvmazeClient::new().with_base_url(server.url()); + let result = client.search_tv("anything").await; + + match result { + Err(TvmazeError::Api { status, .. }) => assert_eq!(status, 503), + other => panic!("expected Api error, got {other:?}"), + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 89595ec..4853828 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -93,6 +93,8 @@ pub fn run() { metadata::fetch_movie_metadata, settings::get_tmdb_token, settings::save_tmdb_token, + settings::get_tv_metadata_source, + settings::save_tv_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 ffe1009..c7d4285 100644 --- a/src-tauri/src/metadata.rs +++ b/src-tauri/src/metadata.rs @@ -1,15 +1,21 @@ //! The Tauri-facing side of "Fetch Data": takes the local season/episode guesses the -//! UI already has (from `guess_episode`/Match) and resolves them against real TMDB +//! UI already has (from `guess_episode`/Match) and resolves them against real online //! metadata. Deliberately layered on top of local matching rather than replacing it - //! 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_ +//! 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. -use aurora_curator_core::web::tmdb::{SeasonEpisode, TmdbClient}; +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; +use crate::settings::{self, TvMetadataSource}; /// One file's local guess, sent up from the UI to be resolved against TMDB. #[derive(Debug, Clone, Deserialize)] @@ -44,34 +50,115 @@ pub struct FetchResult { pub outcome: FetchOutcome, } -/// Resolves a batch of local guesses against TMDB. 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. +/// One episode of a resolved season - the shape both TMDB's and TVmaze's own +/// per-source episode types get mapped into by `AnyTvClient::fetch_season`, so the +/// caching/lookup loop in `run_fetch` never needs to know which source produced a +/// result. +struct LocalEpisode { + episode_number: i32, + title: String, + air_date: Option, +} + +type SeasonLookup = Result<(String, Vec), String>; + +/// Either TV metadata client, picked once per `fetch_metadata` call based on the +/// stored `TvMetadataSource` setting, then reused across every item in the batch (so a +/// setting change mid-batch can't produce mixed-source results within one Fetch Data +/// click). +enum AnyTvClient { + Tmdb(TmdbClient), + Tvmaze(TvmazeClient), +} + +impl AnyTvClient { + /// The human-readable source name, used only in error messages so a "no match" + /// failure tells the user which source actually came up empty. + fn source_name(&self) -> &'static str { + match self { + AnyTvClient::Tmdb(_) => "TMDB", + AnyTvClient::Tvmaze(_) => "TVmaze", + } + } + + async fn fetch_season(&self, series_query: &str, season: i32) -> SeasonLookup { + match self { + AnyTvClient::Tmdb(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.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(), + )) + } + AnyTvClient::Tvmaze(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.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(), + )) + } + } + } +} + +/// 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). #[tauri::command] pub async fn fetch_metadata(app: AppHandle, items: Vec) -> Vec { - 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(); + 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(); + }; + AnyTvClient::Tmdb(TmdbClient::new(token)) + } + TvMetadataSource::Tvmaze => AnyTvClient::Tvmaze(TvmazeClient::new()), }; - tauri::async_runtime::spawn(run_fetch(token, items)) + tauri::async_runtime::spawn(run_fetch(client, items)) .await .unwrap_or_default() } -type SeasonLookup = Result<(String, Vec), String>; - -async fn run_fetch(token: String, items: Vec) -> Vec { - let client = TmdbClient::new(token); - +async fn run_fetch(client: AnyTvClient, items: Vec) -> Vec { // Cache per (series_query, season): a whole loaded season's worth of files only // needs one search + one season-episode-list fetch, not one per file. let mut season_cache: HashMap<(String, i32), SeasonLookup> = HashMap::new(); @@ -81,7 +168,7 @@ async fn run_fetch(token: String, items: Vec) -> Vec { let key = (item.series_query.clone(), item.season); if !season_cache.contains_key(&key) { - let looked_up = fetch_season(&client, &item.series_query, item.season).await; + let looked_up = client.fetch_season(&item.series_query, item.season).await; season_cache.insert(key.clone(), looked_up); } @@ -103,19 +190,6 @@ async fn run_fetch(token: String, items: Vec) -> Vec { results } -async fn fetch_season(client: &TmdbClient, series_query: &str, season: i32) -> SeasonLookup { - let best = client - .search_tv_best_match(series_query) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("no TMDB match for \"{series_query}\""))?; - let episodes = client - .season_episodes(best.id, season) - .await - .map_err(|e| e.to_string())?; - Ok((best.name, episodes)) -} - /// One file's local movie-title guess, sent up from the UI to be resolved against /// TMDB. Mirrors `FetchQuery`, but for the movie side of "Fetch Data". #[derive(Debug, Clone, Deserialize)] diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 755954e..5aecc9b 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,6 +1,7 @@ -//! Small persisted app settings - currently just the TMDB API token ("Fetch Data" needs -//! it; everything else in the app works offline without any settings at all). Stored as -//! plain JSON in Tauri's per-OS app config directory (e.g. +//! 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 +//! as plain JSON in Tauri's per-OS app config directory (e.g. //! `~/Library/Application Support/com.auroracurator.app/settings.json` on macOS, //! `~/.config/aurora-curator/settings.json` on Linux) - not the original FileBot's config //! location, since that's Java Preferences-backed and OS/JVM-specific; this is Aurora Curator's @@ -11,10 +12,28 @@ use std::fs; use std::path::PathBuf; use tauri::{AppHandle, Manager}; +/// 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. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TvMetadataSource { + Tmdb, + Tvmaze, +} + +impl Default for TvMetadataSource { + fn default() -> Self { + TvMetadataSource::Tmdb + } +} + #[derive(Debug, Default, Serialize, Deserialize)] struct StoredSettings { #[serde(default)] tmdb_token: Option, + #[serde(default)] + tv_metadata_source: TvMetadataSource, } fn settings_path(app: &AppHandle) -> Result { @@ -59,3 +78,50 @@ pub fn save_tmdb_token(app: AppHandle, token: String) -> Result<(), String> { settings.tmdb_token = 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 +} + +#[tauri::command] +pub fn get_tv_metadata_source(app: AppHandle) -> TvMetadataSource { + tv_metadata_source(&app) +} + +#[tauri::command] +pub fn save_tv_metadata_source(app: AppHandle, source: TvMetadataSource) -> Result<(), String> { + let mut settings = load(&app); + settings.tv_metadata_source = source; + save(&app, &settings) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tv_metadata_source_defaults_to_tmdb() { + assert_eq!(TvMetadataSource::default(), TvMetadataSource::Tmdb); + } + + #[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. + assert_eq!(serde_json::to_string(&TvMetadataSource::Tmdb).unwrap(), "\"tmdb\""); + assert_eq!(serde_json::to_string(&TvMetadataSource::Tvmaze).unwrap(), "\"tvmaze\""); + } + + #[test] + fn stored_settings_without_a_tv_metadata_source_field_still_deserializes() { + // Settings written before this field existed (just `{"tmdbToken": "..."}` or + // similar) must still load cleanly, defaulting to Tmdb - same + // schema-evolution pattern used for `HistoryBatch.action` (see project memory). + let old_json = r#"{"tmdb_token":"abc123"}"#; + let parsed: StoredSettings = serde_json::from_str(old_json).unwrap(); + assert_eq!(parsed.tv_metadata_source, TvMetadataSource::Tmdb); + assert_eq!(parsed.tmdb_token, Some("abc123".to_string())); + } +} diff --git a/src/components/RenamePanel.tsx b/src/components/RenamePanel.tsx index acb752d..a8fc2a4 100644 --- a/src/components/RenamePanel.tsx +++ b/src/components/RenamePanel.tsx @@ -104,10 +104,17 @@ type RenameAction = "move" | "copy" | "symlink"; // "override" replaces it outright (irreversible for whatever gets overwritten). 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"; + // Sent to `fetch_metadata`: one file's local guess (from Match) to resolve against -// real TMDB data. Needs a series name + season/episode to search with - exactly what -// Match already produces offline, which is why Fetch Data requires Match to have run -// first rather than doing its own from-scratch identification. +// real online TV metadata (TMDB or TVmaze, per the stored TvMetadataSource). Needs a +// series name + season/episode to search with - exactly what Match already produces +// offline, which is why Fetch Data requires Match to have run first rather than doing +// its own from-scratch identification. interface FetchQuery { id: string; seriesQuery: string; @@ -312,6 +319,19 @@ const CONFLICT_POLICY_LABELS: Record = { + tmdb: { + button: "TMDB", + description: "Uses TheMovieDB for TV episode titles and air dates — needs the API token below.", + }, + tvmaze: { + button: "TVmaze", + description: "Uses TVmaze for TV episode titles and air dates — free, no API token needed at all.", + }, +}; + function realFileToItem(entry: FileEntry): Item { return { id: entry.path, @@ -405,6 +425,10 @@ 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); + // 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. + const [tvSource, setTvSource] = useState("tmdb"); const [settingsOpen, setSettingsOpen] = useState(false); const [tmdbTokenInput, setTmdbTokenInput] = useState(""); const [savingSettings, setSavingSettings] = useState(false); @@ -438,6 +462,12 @@ export default function RenamePanel() { .catch(() => setHasTmdbToken(false)); }, []); + useEffect(() => { + invoke("get_tv_metadata_source") + .then(setTvSource) + .catch(() => setTvSource("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); @@ -919,21 +949,29 @@ export default function RenamePanel() { } }; - // "Fetch Data": resolve each already-Matched file's local guess against real TMDB + // Persists the TV-source choice (see Fetch Data Settings modal below). Applied + // immediately in the UI regardless of whether the save round-trip succeeds, same as + // the rest of this app's settings - a failed disk write shouldn't block using the + // app for the rest of the session, just mean the choice doesn't survive a restart. + const saveTvSource = async (source: TvMetadataSource) => { + setTvSource(source); + try { + await invoke("save_tv_metadata_source", { source }); + } catch (err) { + setStatus(`Couldn't save the TV source setting: ${err}`); + window.clearTimeout((saveTvSource as any)._t); + (saveTvSource as any)._t = window.setTimeout(() => setStatus(null), 4000); + } + }; + + // "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 and - // movie items are resolved via separate Tauri commands, run concurrently, and merged - // back into a single set of counts/status message. + // 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, + // concurrently, and merge back into a single set of counts/status message. const runFetch = async () => { - if (hasTmdbToken === false) { - setStatus("Add a free TMDB API token 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); - return; - } - const tvTargets = items.filter( (i) => !i.isSample && i.mediaKind === "tv" && i.bindings?.n && i.bindings?.season && i.bindings?.episode ); @@ -947,6 +985,18 @@ 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."); + setSettingsOpen(true); + window.clearTimeout((runFetch as any)._t); + (runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000); + return; + } + setFetching(true); try { const [tvResults, movieResults] = await Promise.all([ @@ -1048,7 +1098,7 @@ export default function RenamePanel() { } const parts = [`Fetched real metadata for ${found} of ${targetCount} file${targetCount === 1 ? "" : "s"}`]; - if (notFound > 0) parts.push(`${notFound} not found on TMDB`); + if (notFound > 0) parts.push(`${notFound} not found`); if (failed > 0) parts.push(`${failed} failed`); setStatus(parts.join(", ") + "."); } catch (err) { @@ -1239,7 +1289,7 @@ export default function RenamePanel() { } label="Fetch Data" onClick={runFetch} disabled={fetching} /> - } label="TMDB Settings" onClick={openSettings} /> + } label="Fetch Data Settings" onClick={openSettings} /> } label="Naming Scheme" onClick={() => setEditingFormat((v) => !v)} /> } @@ -1312,8 +1362,10 @@ export default function RenamePanel() { like resolution, source (BluRay/HDTV/WEB-DL/…), video/audio codec, and release group — none of this 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 TMDB metadata for the real title, episode title, and - air/release date — needs a free TMDB API token set in TMDB Settings first. A batch can + 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 field names ({"{n}"}, {"{s00e00}"}, {"{t}"}, {"{y}"}, {"{vf}"}, {"{vc}"}, {"{ac}"}, {"{group}"}, etc.) still @@ -1431,13 +1483,27 @@ export default function RenamePanel() { {settingsOpen && (
!savingSettings && setSettingsOpen(false)}>
e.stopPropagation()}> -

TMDB Settings

+

Fetch Data Settings

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

{TV_SOURCE_LABELS[tvSource].description}

- Fetch Data uses{" "} + Movies always resolve against{" "} TheMovieDB {" "} - to resolve local guesses into real episode titles and air dates. Get a free{" "} + 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.