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:
Renamr Scaffold
2026-08-29 12:25:45 +00:00
parent 22a3da9f6f
commit c158befa83
11 changed files with 1234 additions and 174 deletions
+73 -17
View File
@@ -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();
}
}
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}