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).
This commit is contained in:
Renamr Scaffold
2026-08-29 10:44:12 +00:00
parent 7097b1ede7
commit 5763c6313b
7 changed files with 729 additions and 96 deletions
+57 -31
View File
@@ -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`,
+6 -3
View File
@@ -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;
@@ -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<i32>,
name: String,
#[serde(default)]
airdate: Option<String>,
}
/// 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<String>,
}
/// 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<String>,
}
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<String>) -> Self {
self.base_url = base_url.into();
self
}
async fn get_json<T: for<'de> Deserialize<'de>>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<T, TvmazeError> {
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::<T>()
.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<Vec<TvResult>, TvmazeError> {
let hits: Vec<SearchHit> = 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<Vec<EpisodeResult>, 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<Vec<SeasonEpisode>, 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<Option<TvResult>, 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<EpisodeInfo, TvmazeError> {
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:?}"),
}
}
}
+2
View File
@@ -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,
+111 -37
View File
@@ -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<String>,
}
type SeasonLookup = Result<(String, Vec<LocalEpisode>), 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<FetchQuery>) -> Vec<FetchResult> {
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<SeasonEpisode>), String>;
async fn run_fetch(token: String, items: Vec<FetchQuery>) -> Vec<FetchResult> {
let client = TmdbClient::new(token);
async fn run_fetch(client: AnyTvClient, items: Vec<FetchQuery>) -> Vec<FetchResult> {
// 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<FetchQuery>) -> Vec<FetchResult> {
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<FetchQuery>) -> Vec<FetchResult> {
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)]
+69 -3
View File
@@ -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<String>,
#[serde(default)]
tv_metadata_source: TvMetadataSource,
}
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
@@ -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()));
}
}
+88 -22
View File
@@ -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<ConflictPolicy, { button: string; descripti
},
};
// Copy for each TvMetadataSource, used in the Fetch Data Settings modal's segmented
// selector and its description note.
const TV_SOURCE_LABELS: Record<TvMetadataSource, { button: string; description: string }> = {
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<boolean | null>(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<TvMetadataSource>("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<TvMetadataSource>("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() {
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
<span className="list-button-gap" />
<ListButton icon={<FetchIcon />} label="Fetch Data" onClick={runFetch} disabled={fetching} />
<ListButton icon={<KeyIcon />} label="TMDB Settings" onClick={openSettings} />
<ListButton icon={<KeyIcon />} label="Fetch Data Settings" onClick={openSettings} />
<ListButton icon={<GearIcon />} 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 <strong>TMDB Settings</strong> 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{" "}
<strong>Fetch Data Settings</strong> (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 (<code>{"{n}"}</code>, <code>{"{s00e00}"}</code>, <code>{"{t}"}</code>, <code>{"{y}"}</code>,
<code>{"{vf}"}</code>, <code>{"{vc}"}</code>, <code>{"{ac}"}</code>, <code>{"{group}"}</code>, etc.) still
@@ -1431,13 +1483,27 @@ export default function RenamePanel() {
{settingsOpen && (
<div className="modal-overlay" onClick={() => !savingSettings && setSettingsOpen(false)}>
<div className="modal-card modal-card-compact" onClick={(e) => e.stopPropagation()}>
<h2 className="modal-title">TMDB Settings</h2>
<h2 className="modal-title">Fetch Data Settings</h2>
<span className="modal-field-label">TV episode source</span>
<div className="action-segmented" role="radiogroup" aria-label="Which source Fetch Data uses for TV episodes">
{(["tmdb", "tvmaze"] as const).map((s) => (
<button
key={s}
type="button"
className={`action-segmented-option${tvSource === s ? " selected" : ""}`}
onClick={() => saveTvSource(s)}
>
{TV_SOURCE_LABELS[s].button}
</button>
))}
</div>
<p className="modal-subtitle">{TV_SOURCE_LABELS[tvSource].description}</p>
<p className="modal-subtitle">
Fetch Data uses{" "}
Movies always resolve against{" "}
<a href="https://www.themoviedb.org/" target="_blank" rel="noreferrer">
TheMovieDB
</a>{" "}
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{" "}
<strong>API Read Access Token</strong> 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.
</p>