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:
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user