Add OMDb as a datasource: TV episodes (third option alongside TMDB/TVmaze) and movies (new second option alongside TMDB)
Movies get their own independent metadata-source setting for the first time (previously hardcoded to TMDB) so the GUI's Fetch Data Settings now has two separate pickers - TV episode source (TMDB/TVmaze/OMDb) and movie source (TMDB/OMDb) - and a batch can freely mix sources across its TV and movie halves. The -rename CLI's --db flag gained omdb alongside tmdb/tvmaze, with movies now looked up online for both tmdb and omdb.
This commit is contained in:
+73
-17
@@ -25,12 +25,13 @@ use aurora_curator_core::format::ExpressionFormat;
|
||||
use aurora_curator_core::matching::{self, ReleaseInfo};
|
||||
use aurora_curator_core::rename::{self, RenameOutcome, RenamePlanItem};
|
||||
use aurora_curator_core::string_bindings;
|
||||
use aurora_curator_core::web::omdb::OmdbClient;
|
||||
use aurora_curator_core::web::tmdb::TmdbClient;
|
||||
use aurora_curator_core::web::tvmaze::TvmazeClient;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::metadata::AnyTvClient;
|
||||
use crate::metadata::{AnyMovieClient, AnyTvClient, MovieLookup};
|
||||
use crate::settings;
|
||||
|
||||
const USAGE: &str = "\
|
||||
@@ -41,16 +42,16 @@ Usage:
|
||||
|
||||
Options:
|
||||
--format <expr> Naming scheme, e.g. \"{show name} - {season number}x{episode number} - {episode name}\" (required)
|
||||
--db <tmdb|tvmaze> Resolve TV episodes online against this source before rendering names (optional;
|
||||
movies are only looked up online when this is tmdb - TVmaze has no movie catalog)
|
||||
--db <tmdb|tvmaze|omdb> Resolve TV episodes online against this source before rendering names (optional;
|
||||
movies are also looked up online when this is tmdb or omdb - TVmaze has no movie catalog)
|
||||
--action <move|copy|symlink|test> What to do with each file (default: move; \"test\" prints the plan without touching anything)
|
||||
--conflict <skip|auto-number|override> What to do when a computed target already exists (default: skip)
|
||||
-non-strict Accepted for FileBot script compatibility; has no effect (local matching has no
|
||||
confidence score to loosen - see the aurora_curator_core::cli module doc)
|
||||
-h, --help Show this help and exit
|
||||
|
||||
A --db source needs a TMDB token already saved through the app's own Fetch Data Settings at least once -
|
||||
the CLI never accepts a token as a command-line argument.
|
||||
A --db tmdb/omdb source needs the matching API token/key already saved through the app's own Fetch Data
|
||||
Settings at least once - the CLI never accepts a token or key as a command-line argument.
|
||||
";
|
||||
|
||||
/// Entry point called from `main.rs`. Returns the process exit code.
|
||||
@@ -93,8 +94,8 @@ fn run_rename(args: RenameCliArgs) -> i32 {
|
||||
};
|
||||
|
||||
// Resolved once up front (not per-file) so a batch never ends up split across two
|
||||
// different saved-token states, and so a missing token fails fast instead of after
|
||||
// however many files were already processed.
|
||||
// different saved-token states, and so a missing token/key fails fast instead of
|
||||
// after however many files were already processed.
|
||||
let tmdb_token = if args.db == Some(TvDbChoice::Tmdb) {
|
||||
match settings::cli_tmdb_token() {
|
||||
Some(token) => Some(token),
|
||||
@@ -108,6 +109,19 @@ fn run_rename(args: RenameCliArgs) -> i32 {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let omdb_api_key = if args.db == Some(TvDbChoice::Omdb) {
|
||||
match settings::cli_omdb_api_key() {
|
||||
Some(key) => Some(key),
|
||||
None => {
|
||||
eprintln!(
|
||||
"error: --db omdb needs an OMDb API key already saved through the app's own Fetch Data Settings"
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let tv_client = match args.db {
|
||||
None => None,
|
||||
@@ -115,10 +129,22 @@ fn run_rename(args: RenameCliArgs) -> i32 {
|
||||
Some(TvDbChoice::Tmdb) => Some(AnyTvClient::Tmdb(TmdbClient::new(
|
||||
tmdb_token.clone().expect("checked above"),
|
||||
))),
|
||||
Some(TvDbChoice::Omdb) => Some(AnyTvClient::Omdb(OmdbClient::new(
|
||||
omdb_api_key.clone().expect("checked above"),
|
||||
))),
|
||||
};
|
||||
// Movies get an online lookup whenever --db tmdb or --db omdb was explicitly
|
||||
// passed - TVmaze has no movie catalog, and no --db at all means "local matching
|
||||
// only" for both.
|
||||
let movie_client = match args.db {
|
||||
Some(TvDbChoice::Tmdb) => Some(AnyMovieClient::Tmdb(TmdbClient::new(
|
||||
tmdb_token.clone().expect("checked above"),
|
||||
))),
|
||||
Some(TvDbChoice::Omdb) => Some(AnyMovieClient::Omdb(OmdbClient::new(
|
||||
omdb_api_key.clone().expect("checked above"),
|
||||
))),
|
||||
Some(TvDbChoice::Tvmaze) | None => None,
|
||||
};
|
||||
// Movies only get an online lookup when --db tmdb was explicitly passed - TVmaze
|
||||
// has no movie catalog, and no --db at all means "local matching only" for both.
|
||||
let movie_client = tmdb_token.map(TmdbClient::new);
|
||||
|
||||
let items = tauri::async_runtime::block_on(build_plan(&files, &compiled, tv_client, movie_client));
|
||||
if items.is_empty() {
|
||||
@@ -219,7 +245,7 @@ async fn build_plan(
|
||||
files: &[PathBuf],
|
||||
compiled: &ExpressionFormat,
|
||||
tv_client: Option<AnyTvClient>,
|
||||
movie_client: Option<TmdbClient>,
|
||||
movie_client: Option<AnyMovieClient>,
|
||||
) -> Vec<RenamePlanItem> {
|
||||
// Cache per (series_query, season), same as the GUI's own `run_fetch` - a whole
|
||||
// season's worth of files only needs one online search + episode-list fetch.
|
||||
@@ -277,16 +303,18 @@ async fn build_plan(
|
||||
}
|
||||
|
||||
if let Some(client) = &movie_client {
|
||||
if let Ok(info) = client.lookup_movie(&movie.title, movie.year).await {
|
||||
bindings.insert("n".to_string(), info.title.clone());
|
||||
bindings.insert("movie name".to_string(), info.title.clone());
|
||||
if let Some(release_date) = &info.release_date {
|
||||
if let Ok(MovieLookup::Found { title, release_date }) =
|
||||
client.lookup_movie(&movie.title, movie.year).await
|
||||
{
|
||||
bindings.insert("n".to_string(), title.clone());
|
||||
bindings.insert("movie name".to_string(), title);
|
||||
if let Some(release_date) = &release_date {
|
||||
bindings.insert("airdate".to_string(), release_date.clone());
|
||||
// Only overwrite the locally-guessed year if Match didn't
|
||||
// already find one in the filename itself - same rule
|
||||
// RenamePanel.tsx's Fetch Data handler uses, since a
|
||||
// filename-carried year is usually more precise than TMDB's
|
||||
// release date for disambiguating remakes.
|
||||
// filename-carried year is usually more precise than the
|
||||
// online source's release date for disambiguating remakes.
|
||||
if movie.year.is_none() {
|
||||
let year: String = release_date.chars().take(4).collect();
|
||||
bindings.insert("y".to_string(), year.clone());
|
||||
@@ -492,4 +520,32 @@ mod tests {
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_fails_cleanly_when_db_omdb_is_requested_without_a_saved_key() {
|
||||
// Mirrors `run_fails_cleanly_when_db_tmdb_is_requested_without_a_saved_token`
|
||||
// above, for the OMDb credential instead.
|
||||
if settings::cli_omdb_api_key().is_some() {
|
||||
eprintln!("skipping: a real OMDb API key is saved in this environment's config dir");
|
||||
return;
|
||||
}
|
||||
|
||||
let dir = temp_dir();
|
||||
let file = dir.join("Breaking.Bad.S05E14.mkv");
|
||||
std::fs::write(&file, b"hello").unwrap();
|
||||
|
||||
let code = run(vec![
|
||||
"-rename".to_string(),
|
||||
"--format".to_string(),
|
||||
"{n}".to_string(),
|
||||
"--db".to_string(),
|
||||
"omdb".to_string(),
|
||||
file.to_string_lossy().to_string(),
|
||||
]);
|
||||
|
||||
assert_eq!(code, 2);
|
||||
assert!(file.exists(), "should fail before touching any file");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,8 +101,12 @@ pub fn run() {
|
||||
metadata::fetch_movie_metadata,
|
||||
settings::get_tmdb_token,
|
||||
settings::save_tmdb_token,
|
||||
settings::get_omdb_api_key,
|
||||
settings::save_omdb_api_key,
|
||||
settings::get_tv_metadata_source,
|
||||
settings::save_tv_metadata_source,
|
||||
settings::get_movie_metadata_source,
|
||||
settings::save_movie_metadata_source,
|
||||
history::list_rename_history,
|
||||
history::undo_rename_batch,
|
||||
presets::list_presets,
|
||||
|
||||
+130
-37
@@ -4,18 +4,20 @@
|
||||
//! 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_
|
||||
//! TV lookups can go against TMDB, TVmaze, or OMDb - 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.
|
||||
//! lookups can go against TMDB or OMDb - whichever `settings::movie_metadata_source`
|
||||
//! currently has stored (see `AnyMovieClient` below), defaulting to TMDB; TVmaze has no
|
||||
//! movie catalog, so it's never an option for the movie half of Fetch Data.
|
||||
|
||||
use aurora_curator_core::web::omdb::OmdbClient;
|
||||
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::{self, TvMetadataSource};
|
||||
use crate::settings::{self, MovieMetadataSource, TvMetadataSource};
|
||||
|
||||
/// One file's local guess, sent up from the UI to be resolved against TMDB.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -72,6 +74,7 @@ pub(crate) type SeasonLookup = Result<(String, Vec<LocalEpisode>), String>;
|
||||
pub(crate) enum AnyTvClient {
|
||||
Tmdb(TmdbClient),
|
||||
Tvmaze(TvmazeClient),
|
||||
Omdb(OmdbClient),
|
||||
}
|
||||
|
||||
impl AnyTvClient {
|
||||
@@ -81,6 +84,7 @@ impl AnyTvClient {
|
||||
match self {
|
||||
AnyTvClient::Tmdb(_) => "TMDB",
|
||||
AnyTvClient::Tvmaze(_) => "TVmaze",
|
||||
AnyTvClient::Omdb(_) => "OMDb",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,36 +128,113 @@ impl AnyTvClient {
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
AnyTvClient::Omdb(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.imdb_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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a movie lookup found - `NotFound` is a normal, expected outcome (nothing
|
||||
/// matched) and is kept distinct from `Err` (a real network/API failure) the same way
|
||||
/// `TmdbError::NotFound`/`OmdbError::NotFound` are - `Err`'s `String` here is that
|
||||
/// underlying error's `Display` text, already source-agnostic by the time it reaches a
|
||||
/// caller.
|
||||
pub(crate) enum MovieLookup {
|
||||
Found { title: String, release_date: Option<String> },
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// Either movie metadata client, picked once per `fetch_movie_metadata` call (or, for
|
||||
/// the CLI, once per `-rename --db` invocation) based on the chosen source - mirrors
|
||||
/// `AnyTvClient` above, but for the movie half of Fetch Data. `pub(crate)`: see
|
||||
/// `LocalEpisode`'s doc comment above.
|
||||
pub(crate) enum AnyMovieClient {
|
||||
Tmdb(TmdbClient),
|
||||
Omdb(OmdbClient),
|
||||
}
|
||||
|
||||
impl AnyMovieClient {
|
||||
pub(crate) async fn lookup_movie(&self, title_query: &str, year: Option<i32>) -> Result<MovieLookup, String> {
|
||||
match self {
|
||||
AnyMovieClient::Tmdb(client) => match client.lookup_movie(title_query, year).await {
|
||||
Ok(info) => Ok(MovieLookup::Found {
|
||||
title: info.title,
|
||||
release_date: info.release_date,
|
||||
}),
|
||||
Err(aurora_curator_core::web::tmdb::TmdbError::NotFound(_)) => Ok(MovieLookup::NotFound),
|
||||
Err(error) => Err(error.to_string()),
|
||||
},
|
||||
AnyMovieClient::Omdb(client) => match client.lookup_movie(title_query, year).await {
|
||||
Ok(info) => Ok(MovieLookup::Found {
|
||||
title: info.title,
|
||||
release_date: info.release_date,
|
||||
}),
|
||||
Err(aurora_curator_core::web::omdb::OmdbError::NotFound(_)) => Ok(MovieLookup::NotFound),
|
||||
Err(error) => Err(error.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the "every item failed the same way" result list a missing-credential guard
|
||||
/// clause returns before any client is even constructed - shared by `fetch_metadata`
|
||||
/// and `fetch_movie_metadata` below so the message and shape stay identical between the
|
||||
/// TV and movie halves of Fetch Data.
|
||||
fn failed_all(items: Vec<FetchQuery>, error: &str) -> Vec<FetchResult> {
|
||||
items
|
||||
.into_iter()
|
||||
.map(|item| FetchResult {
|
||||
id: item.id,
|
||||
outcome: FetchOutcome::Failed { error: error.to_string() },
|
||||
})
|
||||
.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).
|
||||
/// TVmaze needs no credential at all, so a batch can run against it with nothing
|
||||
/// configured; TMDB and OMDb each need their own saved credential before a batch
|
||||
/// resolved against them will succeed (independently of whichever source movies are
|
||||
/// using, elsewhere in this file).
|
||||
#[tauri::command]
|
||||
pub async fn fetch_metadata(app: AppHandle, items: Vec<FetchQuery>) -> Vec<FetchResult> {
|
||||
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();
|
||||
return failed_all(items, "No TMDB API token configured — open Settings to add one.");
|
||||
};
|
||||
AnyTvClient::Tmdb(TmdbClient::new(token))
|
||||
}
|
||||
TvMetadataSource::Tvmaze => AnyTvClient::Tvmaze(TvmazeClient::new()),
|
||||
TvMetadataSource::Omdb => {
|
||||
let Some(key) = settings::omdb_api_key(&app) else {
|
||||
return failed_all(items, "No OMDb API key configured — open Settings to add one.");
|
||||
};
|
||||
AnyTvClient::Omdb(OmdbClient::new(key))
|
||||
}
|
||||
};
|
||||
|
||||
tauri::async_runtime::spawn(run_fetch(client, items))
|
||||
@@ -226,41 +307,53 @@ pub struct MovieFetchResult {
|
||||
pub outcome: MovieFetchOutcome,
|
||||
}
|
||||
|
||||
/// Resolves a batch of local movie-title guesses against TMDB. Mirrors
|
||||
/// Resolves a batch of local movie-title guesses against real movie metadata. Mirrors
|
||||
/// `fetch_metadata`, but each query is independent (no per-season caching the way TV
|
||||
/// episodes benefit from) since a movie search result already carries everything
|
||||
/// needed in one request.
|
||||
/// needed in one request (well, one search plus one detail request for OMDb - see
|
||||
/// `omdb::OmdbClient::lookup_movie` - but that's an implementation detail this command
|
||||
/// doesn't need to know about).
|
||||
#[tauri::command]
|
||||
pub async fn fetch_movie_metadata(app: AppHandle, items: Vec<MovieFetchQuery>) -> Vec<MovieFetchResult> {
|
||||
let Some(token) = settings::tmdb_token(&app) else {
|
||||
return items
|
||||
.into_iter()
|
||||
.map(|item| MovieFetchResult {
|
||||
id: item.id,
|
||||
outcome: MovieFetchOutcome::Failed {
|
||||
error: "No TMDB API token configured — open Settings to add one.".to_string(),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
let client = match settings::movie_metadata_source(&app) {
|
||||
MovieMetadataSource::Tmdb => {
|
||||
let Some(token) = settings::tmdb_token(&app) else {
|
||||
return failed_all_movies(items, "No TMDB API token configured — open Settings to add one.");
|
||||
};
|
||||
AnyMovieClient::Tmdb(TmdbClient::new(token))
|
||||
}
|
||||
MovieMetadataSource::Omdb => {
|
||||
let Some(key) = settings::omdb_api_key(&app) else {
|
||||
return failed_all_movies(items, "No OMDb API key configured — open Settings to add one.");
|
||||
};
|
||||
AnyMovieClient::Omdb(OmdbClient::new(key))
|
||||
}
|
||||
};
|
||||
|
||||
tauri::async_runtime::spawn(run_movie_fetch(token, items))
|
||||
tauri::async_runtime::spawn(run_movie_fetch(client, items))
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn run_movie_fetch(token: String, items: Vec<MovieFetchQuery>) -> Vec<MovieFetchResult> {
|
||||
let client = TmdbClient::new(token);
|
||||
/// Same idea as `failed_all` above, for the movie side's own result shape.
|
||||
fn failed_all_movies(items: Vec<MovieFetchQuery>, error: &str) -> Vec<MovieFetchResult> {
|
||||
items
|
||||
.into_iter()
|
||||
.map(|item| MovieFetchResult {
|
||||
id: item.id,
|
||||
outcome: MovieFetchOutcome::Failed { error: error.to_string() },
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn run_movie_fetch(client: AnyMovieClient, items: Vec<MovieFetchQuery>) -> Vec<MovieFetchResult> {
|
||||
let mut results = Vec::with_capacity(items.len());
|
||||
|
||||
for item in items {
|
||||
let outcome = match client.lookup_movie(&item.title_query, item.year).await {
|
||||
Ok(info) => MovieFetchOutcome::Found {
|
||||
title: info.title,
|
||||
release_date: info.release_date,
|
||||
},
|
||||
Err(aurora_curator_core::web::tmdb::TmdbError::NotFound(_)) => MovieFetchOutcome::NotFound,
|
||||
Err(error) => MovieFetchOutcome::Failed { error: error.to_string() },
|
||||
Ok(MovieLookup::Found { title, release_date }) => MovieFetchOutcome::Found { title, release_date },
|
||||
Ok(MovieLookup::NotFound) => MovieFetchOutcome::NotFound,
|
||||
Err(error) => MovieFetchOutcome::Failed { error },
|
||||
};
|
||||
|
||||
results.push(MovieFetchResult { id: item.id, outcome });
|
||||
|
||||
+102
-13
@@ -1,6 +1,6 @@
|
||||
//! 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
|
||||
//! Small persisted app settings - the TMDB API token, the OMDb API key, and which TV
|
||||
//! and movie metadata sources 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 the OS's standard per-app config directory, joined with the app's
|
||||
//! bundle identifier - `~/Library/Application Support/com.auroracurator.app/settings.json`
|
||||
//! on macOS, `~/.config/com.auroracurator.app/settings.json` on Linux, `%APPDATA%\
|
||||
@@ -11,10 +11,10 @@
|
||||
//! The GUI reaches that directory through a running Tauri `AppHandle`
|
||||
//! (`app.path().app_config_dir()`); the `-rename` CLI mode (`cli.rs`) never builds a
|
||||
//! Tauri app at all, so it needs the exact same directory computed a different way -
|
||||
//! see [`cli_tmdb_token`] below, which replicates Tauri's own `dirs::config_dir()
|
||||
//! .join(identifier)` computation directly (confirmed against Tauri 2.11's
|
||||
//! `path/desktop.rs` source) so both code paths are guaranteed to read/write the same
|
||||
//! file. [`APP_CONFIG_IDENTIFIER`] must be kept in sync with `tauri.conf.json`'s
|
||||
//! see [`cli_tmdb_token`]/[`cli_omdb_api_key`] below, which replicate Tauri's own
|
||||
//! `dirs::config_dir().join(identifier)` computation directly (confirmed against Tauri
|
||||
//! 2.11's `path/desktop.rs` source) so both code paths are guaranteed to read/write the
|
||||
//! same file. [`APP_CONFIG_IDENTIFIER`] must be kept in sync with `tauri.conf.json`'s
|
||||
//! `identifier` field by hand - there's no way to share a constant with the JSON config.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -25,14 +25,15 @@ use tauri::{AppHandle, Manager};
|
||||
/// Must match `tauri.conf.json`'s `identifier` field exactly - see the module doc.
|
||||
const APP_CONFIG_IDENTIFIER: &str = "com.auroracurator.app";
|
||||
|
||||
/// 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.
|
||||
/// Which online source Fetch Data resolves TV episodes against. TVmaze needs no API
|
||||
/// key at all; TMDB and OMDb each need their own saved credential (see
|
||||
/// [`tmdb_token`]/[`omdb_api_key`]) before a batch resolved against them will succeed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TvMetadataSource {
|
||||
Tmdb,
|
||||
Tvmaze,
|
||||
Omdb,
|
||||
}
|
||||
|
||||
impl Default for TvMetadataSource {
|
||||
@@ -41,12 +42,32 @@ impl Default for TvMetadataSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which online source Fetch Data resolves movies against. Unlike TV, there's no
|
||||
/// zero-setup option here (TVmaze has no movie catalog) - both choices need their own
|
||||
/// saved credential.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MovieMetadataSource {
|
||||
Tmdb,
|
||||
Omdb,
|
||||
}
|
||||
|
||||
impl Default for MovieMetadataSource {
|
||||
fn default() -> Self {
|
||||
MovieMetadataSource::Tmdb
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct StoredSettings {
|
||||
#[serde(default)]
|
||||
tmdb_token: Option<String>,
|
||||
#[serde(default)]
|
||||
omdb_api_key: Option<String>,
|
||||
#[serde(default)]
|
||||
tv_metadata_source: TvMetadataSource,
|
||||
#[serde(default)]
|
||||
movie_metadata_source: MovieMetadataSource,
|
||||
}
|
||||
|
||||
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
@@ -106,6 +127,36 @@ pub fn save_tmdb_token(app: AppHandle, token: String) -> Result<(), String> {
|
||||
save(&app, &settings)
|
||||
}
|
||||
|
||||
/// Returns the stored OMDb API key, or `None` if nothing (or only blank text) is
|
||||
/// saved. Mirrors [`tmdb_token`] exactly - OMDb calls its credential a "key" rather
|
||||
/// than a "token", but it's the same kind of thing (a plain saved secret, entered once
|
||||
/// through Fetch Data Settings, never handled by anything but the user and this app).
|
||||
pub fn omdb_api_key(app: &AppHandle) -> Option<String> {
|
||||
load(app).omdb_api_key.filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Same as [`omdb_api_key`], but for the `-rename` CLI path - mirrors [`cli_tmdb_token`]
|
||||
/// exactly, see its doc comment for why this can't just ask an `AppHandle`.
|
||||
pub fn cli_omdb_api_key() -> Option<String> {
|
||||
let dir = dirs::config_dir()?.join(APP_CONFIG_IDENTIFIER);
|
||||
let contents = fs::read_to_string(dir.join("settings.json")).ok()?;
|
||||
let settings: StoredSettings = serde_json::from_str(&contents).ok()?;
|
||||
settings.omdb_api_key.filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_omdb_api_key(app: AppHandle) -> Option<String> {
|
||||
omdb_api_key(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_omdb_api_key(app: AppHandle, key: String) -> Result<(), String> {
|
||||
let mut settings = load(&app);
|
||||
let trimmed = key.trim().to_string();
|
||||
settings.omdb_api_key = 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
|
||||
@@ -123,6 +174,24 @@ pub fn save_tv_metadata_source(app: AppHandle, source: TvMetadataSource) -> Resu
|
||||
save(&app, &settings)
|
||||
}
|
||||
|
||||
/// Returns the stored movie metadata source (defaults to TMDB if nothing's been
|
||||
/// chosen - movies were TMDB-only before OMDb added a second option).
|
||||
pub fn movie_metadata_source(app: &AppHandle) -> MovieMetadataSource {
|
||||
load(app).movie_metadata_source
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_movie_metadata_source(app: AppHandle) -> MovieMetadataSource {
|
||||
movie_metadata_source(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_movie_metadata_source(app: AppHandle, source: MovieMetadataSource) -> Result<(), String> {
|
||||
let mut settings = load(&app);
|
||||
settings.movie_metadata_source = source;
|
||||
save(&app, &settings)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -134,11 +203,19 @@ mod tests {
|
||||
|
||||
#[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.
|
||||
// The frontend sends/receives this as a plain string ("tmdb"/"tvmaze"/"omdb") -
|
||||
// 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\"");
|
||||
assert_eq!(serde_json::to_string(&TvMetadataSource::Omdb).unwrap(), "\"omdb\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movie_metadata_source_defaults_to_tmdb_and_serializes_as_lowercase() {
|
||||
assert_eq!(MovieMetadataSource::default(), MovieMetadataSource::Tmdb);
|
||||
assert_eq!(serde_json::to_string(&MovieMetadataSource::Tmdb).unwrap(), "\"tmdb\"");
|
||||
assert_eq!(serde_json::to_string(&MovieMetadataSource::Omdb).unwrap(), "\"omdb\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -169,4 +246,16 @@ mod tests {
|
||||
assert_eq!(parsed.tv_metadata_source, TvMetadataSource::Tmdb);
|
||||
assert_eq!(parsed.tmdb_token, Some("abc123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_settings_without_omdb_fields_still_deserializes() {
|
||||
// Same schema-evolution guarantee as the TV-metadata-source test above, for the
|
||||
// two fields OMDb support added: an old settings.json with neither
|
||||
// `omdb_api_key` nor `movie_metadata_source` present must still load cleanly.
|
||||
let old_json = r#"{"tmdb_token":"abc123","tv_metadata_source":"tvmaze"}"#;
|
||||
let parsed: StoredSettings = serde_json::from_str(old_json).unwrap();
|
||||
assert_eq!(parsed.omdb_api_key, None);
|
||||
assert_eq!(parsed.movie_metadata_source, MovieMetadataSource::Tmdb);
|
||||
assert_eq!(parsed.tv_metadata_source, TvMetadataSource::Tvmaze);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user