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