Add TMDB metadata client and Fetch Data: real episode titles/air dates via TMDB, with a Settings modal for the API token

This commit is contained in:
Renamr Scaffold
2026-08-29 03:01:19 +00:00
parent bab9bfa1ec
commit d1fc4039a7
12 changed files with 1231 additions and 27 deletions
+5
View File
@@ -1,4 +1,6 @@
mod files;
mod metadata;
mod settings;
use renamr_core::format::ExpressionFormat;
use renamr_core::matching::{self, EpisodeGuess};
@@ -46,6 +48,9 @@ pub fn run() {
render_expression,
guess_episode,
execute_rename,
metadata::fetch_metadata,
settings::get_tmdb_token,
settings::save_tmdb_token,
files::pick_files,
files::pick_folder,
files::expand_dropped_paths,
+117
View File
@@ -0,0 +1,117 @@
//! 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
//! 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.
use renamr_core::web::tmdb::{SeasonEpisode, TmdbClient};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tauri::AppHandle;
use crate::settings;
/// One file's local guess, sent up from the UI to be resolved against TMDB.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FetchQuery {
/// Echoed back in the result so the frontend can map it back to the right item.
pub id: String,
pub series_query: String,
pub season: i32,
pub episode: i32,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum FetchOutcome {
Found {
series_name: String,
episode_title: String,
air_date: Option<String>,
},
/// Either nothing on TMDB matched the series name, or the show was found but that
/// season doesn't have this episode number.
NotFound,
Failed {
error: String,
},
}
#[derive(Debug, Clone, Serialize)]
pub struct FetchResult {
pub id: String,
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.
#[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();
};
tauri::async_runtime::spawn(run_fetch(token, 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);
// 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();
let mut results = Vec::with_capacity(items.len());
for item in items {
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;
season_cache.insert(key.clone(), looked_up);
}
let outcome = match season_cache.get(&key).expect("just inserted") {
Ok((series_name, episodes)) => match episodes.iter().find(|e| e.episode_number == item.episode) {
Some(found) => FetchOutcome::Found {
series_name: series_name.clone(),
episode_title: found.title.clone(),
air_date: found.air_date.clone(),
},
None => FetchOutcome::NotFound,
},
Err(error) => FetchOutcome::Failed { error: error.clone() },
};
results.push(FetchResult { id: item.id, outcome });
}
results
}
async fn fetch_season(client: &TmdbClient, series_query: &str, season: i32) -> SeasonLookup {
let search_results = client.search_tv(series_query).await.map_err(|e| e.to_string())?;
let best = search_results
.into_iter()
.next()
.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))
}
+61
View File
@@ -0,0 +1,61 @@
//! 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.
//! `~/Library/Application Support/com.renamr.app/settings.json` on macOS,
//! `~/.config/renamr/settings.json` on Linux) - not the original FileBot's config
//! location, since that's Java Preferences-backed and OS/JVM-specific; this is Renamr's
//! own, analogous location for its own settings.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
#[derive(Debug, Default, Serialize, Deserialize)]
struct StoredSettings {
#[serde(default)]
tmdb_token: Option<String>,
}
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_config_dir()
.map_err(|e| format!("couldn't resolve the app config directory: {e}"))?;
fs::create_dir_all(&dir).map_err(|e| format!("couldn't create {}: {e}", dir.display()))?;
Ok(dir.join("settings.json"))
}
fn load(app: &AppHandle) -> StoredSettings {
let Ok(path) = settings_path(app) else {
return StoredSettings::default();
};
fs::read_to_string(path)
.ok()
.and_then(|contents| serde_json::from_str(&contents).ok())
.unwrap_or_default()
}
fn save(app: &AppHandle, settings: &StoredSettings) -> Result<(), String> {
let path = settings_path(app)?;
let json = serde_json::to_string_pretty(settings).map_err(|e| e.to_string())?;
fs::write(&path, json).map_err(|e| format!("couldn't write {}: {e}", path.display()))
}
/// Returns the stored TMDB token, or `None` if nothing (or only blank text) is saved.
pub fn tmdb_token(app: &AppHandle) -> Option<String> {
load(app).tmdb_token.filter(|t| !t.trim().is_empty())
}
#[tauri::command]
pub fn get_tmdb_token(app: AppHandle) -> Option<String> {
tmdb_token(&app)
}
#[tauri::command]
pub fn save_tmdb_token(app: AppHandle, token: String) -> Result<(), String> {
let mut settings = load(&app);
let trimmed = token.trim().to_string();
settings.tmdb_token = if trimmed.is_empty() { None } else { Some(trimmed) };
save(&app, &settings)
}