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:
Generated
+1
@@ -105,6 +105,7 @@ name = "aurora-curator"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aurora-curator-core",
|
||||
"dirs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
|
||||
@@ -9,10 +9,11 @@ native file/folder loading and drag-and-drop, local filename-based season/episod
|
||||
movie title/year* detection ("Match") is working, real online metadata lookups
|
||||
("Fetch Data") are working for both TV episodes and movies — including fuzzy title
|
||||
disambiguation, so a search that returns several similarly-named results doesn't just
|
||||
take the source's top-ranked hit. TV episodes can resolve against either TMDB or
|
||||
TVmaze — user-selectable in Fetch Data Settings, TMDB stays the default and TVmaze
|
||||
needs no API token at all — while movies always resolve against TMDB, since TVmaze has
|
||||
no movie catalog. The actual on-disk operation is wired up (with a
|
||||
take the source's top-ranked hit. TV episodes can resolve against TMDB, TVmaze, or
|
||||
OMDb, and movies against TMDB or OMDb — each picked independently in Fetch Data
|
||||
Settings, TMDB stays the default for both, TVmaze needs no API token at all (it has no
|
||||
movie catalog, so it's TV-only), and TMDB/OMDb each need their own free saved
|
||||
credential before a batch resolved against them will succeed. The actual on-disk operation is wired up (with a
|
||||
confirmation step showing the full old-path/new-path plan before anything touches disk,
|
||||
a per-row ✕ to drop an individual file from that plan at the last step, a Move/Copy/
|
||||
Symlink selector for what to actually do at each target, and — independently — a Skip/
|
||||
@@ -135,10 +136,13 @@ crates/aurora-curator-core/ Pure Rust core: naming-expression engine (ported),
|
||||
the same module (`guess_release_info` — resolution, source, video/
|
||||
audio codec, release group), text-similarity scoring for
|
||||
disambiguating search results (`similarity/`, partial
|
||||
`EpisodeMetrics` port), two web metadata clients — `web/tmdb.rs`
|
||||
(TV search + episode lookup and movie search + lookup) and
|
||||
`EpisodeMetrics` port), three web metadata clients — `web/tmdb.rs`
|
||||
(TV search + episode lookup and movie search + lookup),
|
||||
`web/tvmaze.rs` (TV search + episode lookup only — TVmaze has no
|
||||
movie catalog, and no API key/auth at all), both disambiguated via
|
||||
movie catalog, and no API key/auth at all), and `web/omdb.rs` (TV
|
||||
*and* movie search + lookup, single flat root-URL REST API, plain
|
||||
`apikey` query-param auth rather than TMDB's Bearer token) — all
|
||||
three disambiguated via
|
||||
`similarity/` and tested against a mocked HTTP server via
|
||||
`mockito`, the on-disk
|
||||
rename engine (`rename/` — Move/Copy/Symlink actions,
|
||||
@@ -150,16 +154,19 @@ crates/aurora-curator-core/ Pure Rust core: naming-expression engine (ported),
|
||||
argument parsing/file expansion (`cli/` — no Tauri or network
|
||||
dependency at all, see "Command-line usage" below). Not yet
|
||||
ported: full media metadata bindings, other web datasource
|
||||
clients (TheTVDB/AniDB/OMDb/etc). No Tauri dependency, so it
|
||||
builds and unit-tests standalone.
|
||||
clients (TheTVDB/AniDB/OpenSubtitles/etc). No Tauri dependency, so
|
||||
it builds and unit-tests standalone.
|
||||
src-tauri/ Tauri app shell: exposes aurora-curator-core as commands to the frontend
|
||||
(naming-scheme rendering, filename matching for both TV and
|
||||
movies, metadata fetch — `metadata.rs` for TV episodes (against
|
||||
whichever of TMDB/TVmaze is the stored source) plus a
|
||||
movie-fetch command alongside it (always TMDB) — executing a rename
|
||||
whichever of TMDB/TVmaze/OMDb is the stored TV source) plus a
|
||||
movie-fetch command alongside it (against whichever of TMDB/OMDb
|
||||
is the stored movie source — independent of the TV source)
|
||||
— executing a rename
|
||||
plan, native file/folder dialogs, drag-and-drop expansion), plus
|
||||
`settings.rs` (TMDB token, and which TV metadata source Fetch Data
|
||||
uses), `history.rs` (rename history/undo
|
||||
`settings.rs` (TMDB token, OMDb API key, and which TV *and* movie
|
||||
metadata source Fetch Data uses — two independent settings),
|
||||
`history.rs` (rename history/undo
|
||||
log), `presets.rs` (up to 9 saved naming schemes) — all three
|
||||
stored as JSON in the OS app-config directory — `cli.rs` (the
|
||||
`-rename` command-line mode's orchestration: builds and executes a
|
||||
@@ -171,7 +178,9 @@ src/ React + TypeScript UI (Vite). Faithful recreation of the
|
||||
two-pane rename screen (Aurora theme) wired to real file loading,
|
||||
drag-and-drop, live naming-scheme preview, local TV/movie
|
||||
matching, metadata fetch for both (behind a Fetch Data Settings
|
||||
modal holding the TMDB/TVmaze source picker and the API token), a
|
||||
modal holding independent TV-source (TMDB/TVmaze/OMDb) and
|
||||
movie-source (TMDB/OMDb) pickers plus the TMDB token and OMDb key
|
||||
inputs), a
|
||||
confirm-before-touching-disk modal for the actual
|
||||
rename, a History panel that can undo a past rename, Align Up/Down
|
||||
list reordering, and a Presets panel (plus `1`-`9` hotkeys) for
|
||||
@@ -200,13 +209,13 @@ npm run tauri dev
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
Verified in the environment this was built in: `cargo test -p aurora-curator-core` (all
|
||||
passing, including the TMDB and TVmaze client tests — those run against a local mock
|
||||
HTTP server via `mockito`, no live network or API key needed to build/test), `cargo check -p
|
||||
aurora-curator` (compiles clean against real Tauri + GTK/WebKit system libraries), and `npm run
|
||||
build` (TypeScript type-checks, Vite bundles). The actual GUI window has **not** been
|
||||
visually verified anywhere yet — that sandbox has no display. Run `npm run tauri dev`
|
||||
on your machine to see and click through it for the first time.
|
||||
Verified in the environment this was built in: `cargo test --workspace` (all 128
|
||||
passing, including the TMDB, TVmaze, and OMDb client tests — those run against a local
|
||||
mock HTTP server via `mockito`, no live network or API key needed to build/test), `cargo
|
||||
check -p aurora-curator` (compiles clean against real Tauri + GTK/WebKit system
|
||||
libraries), and `npm run build` (TypeScript type-checks, Vite bundles). The actual GUI
|
||||
window has **not** been visually verified anywhere yet — that sandbox has no display.
|
||||
Run `npm run tauri dev` on your machine to see and click through it for the first time.
|
||||
|
||||
### Setting up metadata sources (for "Fetch Data")
|
||||
|
||||
@@ -214,22 +223,34 @@ Everything else in the app works fully offline; "Fetch Data" is the one feature
|
||||
talks to the network, and it's the only one that needs any setup at all — and even that
|
||||
setup is optional for TV.
|
||||
|
||||
TV episodes can resolve against either of two sources, picked in the app (click the key
|
||||
icon, "Fetch Data Settings", next to Fetch Data):
|
||||
TV episodes and movies each have their own independent source picker in the app (click
|
||||
the key icon, "Fetch Data Settings", next to Fetch Data) — a batch can freely mix TV and
|
||||
movie files resolved against two different sources at once:
|
||||
|
||||
- **[TVmaze](https://www.tvmaze.com/)** — free, no account or API token needed at all.
|
||||
Just pick it and go.
|
||||
- **[TMDB](https://www.themoviedb.org/)** — the default, and the only option for movies
|
||||
(TVmaze has no movie catalog). Needs a free account:
|
||||
- **[TVmaze](https://www.tvmaze.com/)** — TV only (it has no movie catalog). Free, no
|
||||
account or API token needed at all. Just pick it and go.
|
||||
- **[TMDB](https://www.themoviedb.org/)** — the default for both TV and movies. Needs a
|
||||
free account:
|
||||
1. Create a TMDB account, then go to Settings → API in your account and generate an
|
||||
**API Read Access Token** (not the older numeric "API Key" — Aurora Curator uses the
|
||||
Bearer token method TMDB documents as the default).
|
||||
2. In the app, open "Fetch Data Settings" and paste the token in. It's saved locally
|
||||
in the OS app-config directory and only ever sent to `api.themoviedb.org`.
|
||||
- **[OMDb](https://www.omdbapi.com/)** — TV and movies, an alternative to TMDB for
|
||||
either or both. Needs a free API key:
|
||||
1. Request a free key at <https://www.omdbapi.com/apikey.aspx> (OMDb emails it to you).
|
||||
2. In the app, open "Fetch Data Settings" and paste the key into the OMDb field. It's
|
||||
saved locally in the OS app-config directory and only ever sent to
|
||||
`www.omdbapi.com`.
|
||||
|
||||
A batch of TV-only files can run entirely on TVmaze with no token configured at all; a
|
||||
batch containing any movies still needs the TMDB token regardless of which TV source is
|
||||
selected.
|
||||
A batch of TV-only files can run entirely on TVmaze with no credential configured at
|
||||
all. Otherwise, whichever of TMDB/OMDb is actually selected — for TV, for movies, or
|
||||
both — needs its own credential saved first; picking TMDB for TV and OMDb for movies (or
|
||||
any other mix) only needs the two credentials that combination actually uses. Note OMDb
|
||||
returns a normal-looking (HTTP 200) "no results" response for both a genuine no-match and
|
||||
an invalid API key, so a bad key currently surfaces as "not found" rather than a clearer
|
||||
credential error — a known OMDb API limitation, not something Aurora Curator can
|
||||
distinguish from its response alone.
|
||||
|
||||
## Command-line usage
|
||||
|
||||
@@ -248,14 +269,15 @@ aurora-curator -rename --format "{show name} - {season number}x{episode number}
|
||||
aliases (`{show name}`, `{episode name}`, `{movie name}`, `{resolution}`, `{video
|
||||
codec}`, `{release group}`, …), or real Rhai script. `{name}`/`{ext}` fall back to the
|
||||
file's own current filename/extension when nothing else matched it.
|
||||
- `--db <tmdb|tvmaze>` (optional) — resolves TV episodes against that online source
|
||||
- `--db <tmdb|tvmaze|omdb>` (optional) — resolves TV episodes against that online source
|
||||
before rendering names, the command-line equivalent of clicking "Fetch Data" after
|
||||
Match. Movies are only looked up online when this is `tmdb` — TVmaze has no movie
|
||||
catalog, and with no `--db` at all every file uses local filename matching only, same
|
||||
as Match without Fetch Data in the GUI. `--db tmdb` needs a TMDB token that's already
|
||||
Match. Movies are also looked up online whenever this is `tmdb` or `omdb` — TVmaze has
|
||||
no movie catalog, so `--db tvmaze` only affects the TV half of a batch — and with no
|
||||
`--db` at all every file uses local filename matching only, same as Match without Fetch
|
||||
Data in the GUI. `--db tmdb`/`--db omdb` each need the matching token/key that's already
|
||||
been saved through the app's own Fetch Data Settings at least once — **the CLI never
|
||||
accepts an API token as an argument**, so there's nothing secret to leak into your
|
||||
shell history or a script.
|
||||
accepts an API token or key as an argument**, so there's nothing secret to leak into
|
||||
your shell history or a script.
|
||||
- `--action <move|copy|symlink|test>` (default `move`) — what to do at each computed
|
||||
target; `test` prints the full plan and touches nothing on disk, useful for checking a
|
||||
naming scheme before committing to it.
|
||||
@@ -312,21 +334,28 @@ ported from, for anyone picking this up:
|
||||
4. **Media detection** — `net.filebot.media.MediaDetection` (1356 lines): ties file-name
|
||||
parsing + matching together to auto-identify what a file is.
|
||||
5. **Web datasource clients** — `net.filebot.web.*` (~40 files): TheMovieDB, TheTVDB,
|
||||
AniDB, OMDb, TVmaze, OpenSubtitles, etc. Two are done: TMDB, for both TV and movies
|
||||
AniDB, OMDb, TVmaze, OpenSubtitles, etc. Three are done: TMDB, for both TV and movies
|
||||
(`crates/aurora-curator-core/src/web/tmdb.rs`, `reqwest` + `serde_json`, Bearer-token auth) —
|
||||
search-by-name plus per-season episode lookup for TV, search-by-title (optionally
|
||||
narrowed by release year via TMDB's own `primary_release_year` filter) for movies —
|
||||
and TVmaze, for TV only (`crates/aurora-curator-core/src/web/tvmaze.rs`, same `reqwest` +
|
||||
TVmaze, for TV only (`crates/aurora-curator-core/src/web/tvmaze.rs`, same `reqwest` +
|
||||
`serde_json` shape, no auth at all — TVmaze has no movie catalog to search, and its
|
||||
per-show episode endpoint returns every season in one flat list rather than TMDB's
|
||||
per-season lookup, so the client fetches once and filters locally, skipping any
|
||||
unnumbered/special episodes rather than panicking on them). Both are wired to "Fetch
|
||||
Data" — a user-selectable "TV episode source" picker in Fetch Data Settings chooses
|
||||
which of the two a batch's TV items resolve against (TMDB stays the default; movies
|
||||
always use TMDB regardless of that picker) — disambiguating multi-result searches via
|
||||
unnumbered/special episodes rather than panicking on them) — and OMDb, for both TV
|
||||
and movies (`crates/aurora-curator-core/src/web/omdb.rs`, same `reqwest` + `serde_json`
|
||||
shape but a single flat root-URL REST API rather than TMDB's per-resource paths, plain
|
||||
`apikey` query-param auth rather than a Bearer token, and human-text release dates
|
||||
(`"16 Jul 2010"`/`"N/A"`) normalized to ISO form locally; a movie search result only
|
||||
carries a bare year, so `lookup_movie` follows up with a by-ID detail request to get
|
||||
the actual release date). All three are wired to "Fetch
|
||||
Data" — independent "TV episode source" (TMDB/TVmaze/OMDb) and "Movie source"
|
||||
(TMDB/OMDb) pickers in Fetch Data Settings choose which source a batch's TV and movie
|
||||
items resolve against respectively (TMDB stays the default for both; a batch can mix
|
||||
sources across its TV and movie halves) — disambiguating multi-result searches via
|
||||
the similarity-scoring pass described in item 3 above rather than blindly trusting
|
||||
either source's top-ranked hit. All other sources (TheTVDB, AniDB, OMDb,
|
||||
OpenSubtitles) are not yet started.
|
||||
any source's top-ranked hit. All other sources (TheTVDB, AniDB, OpenSubtitles) are not
|
||||
yet started.
|
||||
6. **Rename engine + CLI** — `net.filebot.cli.CmdlineOperations` (1235 lines) and
|
||||
`ArgumentBean` (415 lines, the full flag surface: `-rename`, `--db`, `--format`,
|
||||
`--action`, `--conflict`, `-get-subtitles`, `-script`, etc.). The rename operation is
|
||||
@@ -348,7 +377,7 @@ ported from, for anyone picking this up:
|
||||
CLI flag surface is done too — `-rename`/`--format`/`--db`/`--action`/`--conflict`/
|
||||
`-non-strict`/`-h` (`crates/aurora-curator-core/src/cli` for pure argument parsing and
|
||||
directory expansion, `src-tauri/src/cli.rs` for the orchestration — local matching,
|
||||
optional TMDB/TVmaze lookup, and the actual rename, all without ever constructing a
|
||||
optional TMDB/TVmaze/OMDb lookup, and the actual rename, all without ever constructing a
|
||||
Tauri `App`); see "Command-line usage" above. `--output <dir>` and `-get-subtitles`/
|
||||
`-script`/the rest of FileBot's non-rename CLI modes are not ported — `-get-
|
||||
subtitles` and `-script` have no analog yet since subtitle-fetching and a script-mode
|
||||
|
||||
@@ -24,16 +24,18 @@
|
||||
use crate::rename::{ConflictPolicy, RenameAction};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Which online source `--db` should resolve TV episodes against. Deliberately its own
|
||||
/// small enum rather than reusing the Tauri crate's `settings::TvMetadataSource` (this
|
||||
/// crate can't depend on that crate, and that enum represents a *persisted GUI
|
||||
/// preference* anyway, not a per-invocation flag) - a CLI run always states its source
|
||||
/// explicitly via `--db` when it wants one; it never falls back to whatever the GUI's
|
||||
/// Fetch Data Settings last had selected.
|
||||
/// Which online source `--db` should resolve TV episodes (and, for `Tmdb`/`Omdb`,
|
||||
/// movies too - see `src-tauri/src/cli.rs`'s `run_rename`) against. Deliberately its
|
||||
/// own small enum rather than reusing the Tauri crate's `settings::TvMetadataSource`/
|
||||
/// `MovieMetadataSource` (this crate can't depend on that crate, and those enums
|
||||
/// represent *persisted GUI preferences* anyway, not a per-invocation flag) - a CLI run
|
||||
/// always states its source explicitly via `--db` when it wants one; it never falls
|
||||
/// back to whatever the GUI's Fetch Data Settings last had selected.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TvDbChoice {
|
||||
Tmdb,
|
||||
Tvmaze,
|
||||
Omdb,
|
||||
}
|
||||
|
||||
/// A fully parsed, valid `-rename` invocation.
|
||||
@@ -117,9 +119,10 @@ pub fn parse_args(args: &[String]) -> Result<CliInvocation, CliError> {
|
||||
db = Some(match value.to_ascii_lowercase().as_str() {
|
||||
"tmdb" => TvDbChoice::Tmdb,
|
||||
"tvmaze" => TvDbChoice::Tvmaze,
|
||||
"omdb" => TvDbChoice::Omdb,
|
||||
other => {
|
||||
return Err(CliError(format!(
|
||||
"unknown --db source \"{other}\" (expected tmdb or tvmaze)"
|
||||
"unknown --db source \"{other}\" (expected tmdb, tvmaze, or omdb)"
|
||||
)))
|
||||
}
|
||||
});
|
||||
@@ -298,6 +301,20 @@ mod tests {
|
||||
other => panic!("expected Rename, got {other:?}"),
|
||||
}
|
||||
|
||||
let parsed = parse_args(&[
|
||||
s("-rename"),
|
||||
s("--format"),
|
||||
s("{n}"),
|
||||
s("--db"),
|
||||
s("OMDB"),
|
||||
s("file.mkv"),
|
||||
])
|
||||
.unwrap();
|
||||
match parsed {
|
||||
CliInvocation::Rename(args) => assert_eq!(args.db, Some(TvDbChoice::Omdb)),
|
||||
other => panic!("expected Rename, got {other:?}"),
|
||||
}
|
||||
|
||||
let err = parse_args(&[
|
||||
s("-rename"),
|
||||
s("--format"),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! Online metadata clients ("Fetch Data"). TMDB (TV + movies) and TVmaze (TV only, no
|
||||
//! API key needed) so far; the original FileBot supports ~40 sources
|
||||
//! (`net.filebot.web.*`) - TMDB was picked first since it's the most widely useful for
|
||||
//! both TV and movies, TVmaze second since it's a zero-setup second TV source (see
|
||||
//! `tvmaze`'s module doc for why movies aren't in scope for it).
|
||||
//! Online metadata clients ("Fetch Data"). TMDB (TV + movies), TVmaze (TV only, no API
|
||||
//! key needed), and OMDb (TV + movies, needs a free API key) so far; the original
|
||||
//! FileBot supports ~40 sources (`net.filebot.web.*`) - TMDB was picked first since
|
||||
//! it's the most widely useful for both TV and movies, TVmaze second since it's a
|
||||
//! zero-setup second TV source (see `tvmaze`'s module doc for why movies aren't in
|
||||
//! scope for it), OMDb third since it's the first *movie*-capable alternative to TMDB
|
||||
//! (see `omdb`'s module doc).
|
||||
|
||||
pub mod omdb;
|
||||
pub mod tmdb;
|
||||
pub mod tvmaze;
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
//! A client for [OMDb](https://www.omdbapi.com/)'s REST API: search for a TV show or
|
||||
//! movie by name, then look up real metadata (a specific episode's title/air date, or a
|
||||
//! movie's canonical title/release date).
|
||||
//!
|
||||
//! A third metadata source alongside TMDB and TVmaze - and, unlike TVmaze, one that
|
||||
//! covers **both** movies and TV, same as TMDB, so it slots into both of Fetch Data's
|
||||
//! source pickers rather than just the TV one. Needs a free OMDb API key
|
||||
//! (<https://www.omdbapi.com/apikey.aspx>), sent as a plain `apikey` query parameter on
|
||||
//! every request - OMDb has no Bearer-token scheme like TMDB, just the one flat query
|
||||
//! param, and (unlike TMDB's REST-path-per-resource shape) every OMDb request hits the
|
||||
//! same root URL with different query parameters (`s=` search, `i=`/`t=` detail lookup,
|
||||
//! `i=`+`Season=` episode listing).
|
||||
//!
|
||||
//! OMDb's own date fields (`Released`, both on movie details and on episodes) come back
|
||||
//! as human text like `"16 Jul 2010"`, or the literal string `"N/A"` when unknown - see
|
||||
//! [`parse_release_date`], which normalizes that into the same `YYYY-MM-DD` shape
|
||||
//! TMDB/TVmaze already use, so the Tauri glue and the naming-scheme `{airdate}` binding
|
||||
//! don't need to know which source produced a result.
|
||||
//!
|
||||
//! Known limitation, deliberately not engineered around: OMDb returns HTTP 200 (not an
|
||||
//! error status) for both "no results" and "invalid API key" - `{"Response":"False",
|
||||
//! "Error":"..."}` either way - so this client can't reliably distinguish "nothing
|
||||
//! matched" from "your key is wrong" the way TMDB's real 401 lets `TmdbError::Api`
|
||||
//! surface a bad token distinctly. Both cases fall through to [`OmdbError::NotFound`]
|
||||
//! here, same as an empty TMDB/TVmaze result set; a user with a broken key just sees "no
|
||||
//! match found" repeatedly rather than a specific "invalid key" message.
|
||||
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::similarity::best_title_match;
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://www.omdbapi.com";
|
||||
|
||||
/// Same threshold TMDB/TVmaze use (see `tmdb::NAME_MATCH_THRESHOLD`) - kept identical
|
||||
/// so switching sources doesn't change how forgiving/strict name matching feels.
|
||||
const NAME_MATCH_THRESHOLD: f64 = 0.35;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum OmdbError {
|
||||
#[error("network error talking to OMDb: {0}")]
|
||||
Network(String),
|
||||
#[error("OMDb API error (status {status}): {message}")]
|
||||
Api { status: u16, message: String },
|
||||
#[error("no OMDb match found for \"{0}\"")]
|
||||
NotFound(String),
|
||||
}
|
||||
|
||||
/// Converts OMDb's `"16 Jul 2010"`-style date text into `YYYY-MM-DD` (matching what
|
||||
/// TMDB/TVmaze already return), or `None` for `"N/A"`/empty/anything unparseable.
|
||||
fn parse_release_date(raw: &str) -> Option<String> {
|
||||
if raw.trim().is_empty() || raw.eq_ignore_ascii_case("n/a") {
|
||||
return None;
|
||||
}
|
||||
let parts: Vec<&str> = raw.split_whitespace().collect();
|
||||
let [day, month, year] = parts[..] else { return None };
|
||||
let day: u32 = day.parse().ok()?;
|
||||
let month = month_number(month)?;
|
||||
let year: u32 = year.parse().ok()?;
|
||||
Some(format!("{year:04}-{month:02}-{day:02}"))
|
||||
}
|
||||
|
||||
fn month_number(name: &str) -> Option<u32> {
|
||||
Some(match name.to_ascii_lowercase().as_str() {
|
||||
"jan" => 1,
|
||||
"feb" => 2,
|
||||
"mar" => 3,
|
||||
"apr" => 4,
|
||||
"may" => 5,
|
||||
"jun" => 6,
|
||||
"jul" => 7,
|
||||
"aug" => 8,
|
||||
"sep" => 9,
|
||||
"oct" => 10,
|
||||
"nov" => 11,
|
||||
"dec" => 12,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct SearchResponse {
|
||||
#[serde(default, rename = "Search")]
|
||||
search: Vec<SearchHit>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct SearchHit {
|
||||
#[serde(rename = "Title")]
|
||||
title: String,
|
||||
#[serde(rename = "imdbID")]
|
||||
imdb_id: String,
|
||||
}
|
||||
|
||||
/// One OMDb TV-show search hit. Only the fields Aurora Curator actually uses are kept.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TvResult {
|
||||
pub imdb_id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// One OMDb movie search hit.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MovieResult {
|
||||
pub imdb_id: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct SeasonResponse {
|
||||
#[serde(default, rename = "Episodes")]
|
||||
episodes: Vec<EpisodeHit>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct EpisodeHit {
|
||||
#[serde(rename = "Title")]
|
||||
title: String,
|
||||
/// A numeric string (`"1"`), but occasionally `"N/A"` for an uncategorized special
|
||||
/// - parsed defensively in `season_episodes`, same "skip rather than panic"
|
||||
/// convention TVmaze's null-`number` episodes use.
|
||||
#[serde(rename = "Episode")]
|
||||
episode: String,
|
||||
#[serde(rename = "Released", default)]
|
||||
released: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct DetailResponse {
|
||||
#[serde(rename = "Released", default)]
|
||||
released: String,
|
||||
}
|
||||
|
||||
/// Real metadata for one episode, resolved from OMDb - same shape as
|
||||
/// `tmdb::EpisodeInfo`/`tvmaze::EpisodeInfo` so the Tauri glue can treat any source's
|
||||
/// result identically.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EpisodeInfo {
|
||||
pub series_name: String,
|
||||
pub episode_title: String,
|
||||
pub air_date: Option<String>,
|
||||
}
|
||||
|
||||
/// One episode of a season, as returned by `season_episodes()` - mirrors
|
||||
/// `tmdb::SeasonEpisode`/`tvmaze::SeasonEpisode`'s field names.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SeasonEpisode {
|
||||
pub episode_number: i32,
|
||||
pub title: String,
|
||||
pub air_date: Option<String>,
|
||||
}
|
||||
|
||||
/// Real metadata for one movie, resolved from OMDb - mirrors `tmdb::MovieInfo`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MovieInfo {
|
||||
pub title: String,
|
||||
pub release_date: Option<String>,
|
||||
}
|
||||
|
||||
pub struct OmdbClient {
|
||||
http: reqwest::Client,
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl OmdbClient {
|
||||
pub fn new(api_key: impl Into<String>) -> Self {
|
||||
Self {
|
||||
http: reqwest::Client::new(),
|
||||
api_key: api_key.into(),
|
||||
base_url: DEFAULT_BASE_URL.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides the API base URL - used by tests to point at a local mock server
|
||||
/// instead of the real OMDb API.
|
||||
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||
self.base_url = base_url.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Every OMDb request hits the same root URL with different query parameters (no
|
||||
/// per-resource paths like TMDB's `/search/movie`) - `apikey` is appended here so
|
||||
/// every call site gets it for free rather than repeating it.
|
||||
async fn get_json<T: for<'de> Deserialize<'de>>(&self, query: &[(&str, &str)]) -> Result<T, OmdbError> {
|
||||
let mut full_query: Vec<(&str, &str)> = query.to_vec();
|
||||
full_query.push(("apikey", self.api_key.as_str()));
|
||||
|
||||
let response = self
|
||||
.http
|
||||
.get(&self.base_url)
|
||||
.query(&full_query)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| OmdbError::Network(e.to_string()))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
let message = response.text().await.unwrap_or_default();
|
||||
return Err(OmdbError::Api { status, message });
|
||||
}
|
||||
|
||||
response
|
||||
.json::<T>()
|
||||
.await
|
||||
.map_err(|e| OmdbError::Network(e.to_string()))
|
||||
}
|
||||
|
||||
/// Searches for a TV show by name. Results come back OMDb-ranked; callers wanting
|
||||
/// "the best guess" should use `search_tv_best_match` instead, same convention as
|
||||
/// the TMDB/TVmaze clients.
|
||||
pub async fn search_tv(&self, query: &str) -> Result<Vec<TvResult>, OmdbError> {
|
||||
let parsed: SearchResponse = self.get_json(&[("s", query), ("type", "series")]).await?;
|
||||
Ok(parsed
|
||||
.search
|
||||
.into_iter()
|
||||
.map(|h| TvResult { imdb_id: h.imdb_id, name: h.title })
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Searches for a TV show by name and returns the single best-matching result (see
|
||||
/// `similarity::best_title_match`), or `None` if nothing is a plausible match at
|
||||
/// all. Mirrors `TmdbClient::search_tv_best_match`/`TvmazeClient::search_tv_best_match`.
|
||||
pub async fn search_tv_best_match(&self, query: &str) -> Result<Option<TvResult>, OmdbError> {
|
||||
let results = self.search_tv(query).await?;
|
||||
Ok(best_title_match(query, &results, |r| r.name.as_str(), NAME_MATCH_THRESHOLD).cloned())
|
||||
}
|
||||
|
||||
/// Fetches one season's episodes for a known OMDb (IMDb) series id.
|
||||
pub async fn season_episodes(&self, series_imdb_id: &str, season: i32) -> Result<Vec<SeasonEpisode>, OmdbError> {
|
||||
let season_str = season.to_string();
|
||||
let parsed: SeasonResponse = self.get_json(&[("i", series_imdb_id), ("Season", &season_str)]).await?;
|
||||
|
||||
Ok(parsed
|
||||
.episodes
|
||||
.into_iter()
|
||||
.filter_map(|e| {
|
||||
let episode_number = e.episode.parse::<i32>().ok()?;
|
||||
Some(SeasonEpisode {
|
||||
episode_number,
|
||||
title: e.title,
|
||||
air_date: parse_release_date(&e.released),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Convenience: search for the best-matching show by name, then fetch a specific
|
||||
/// episode. Same disambiguation approach as the TMDB/TVmaze clients - picks
|
||||
/// whichever result's name is textually closest to `series_query`, and reports
|
||||
/// `NotFound` instead of guessing when nothing is a plausible match.
|
||||
pub async fn lookup_episode(
|
||||
&self,
|
||||
series_query: &str,
|
||||
season: i32,
|
||||
episode: i32,
|
||||
) -> Result<EpisodeInfo, OmdbError> {
|
||||
let best = self
|
||||
.search_tv_best_match(series_query)
|
||||
.await?
|
||||
.ok_or_else(|| OmdbError::NotFound(series_query.to_string()))?;
|
||||
|
||||
let episodes = self.season_episodes(&best.imdb_id, season).await?;
|
||||
let found = episodes
|
||||
.into_iter()
|
||||
.find(|e| e.episode_number == episode)
|
||||
.ok_or_else(|| OmdbError::NotFound(format!("S{season:02}E{episode:02}")))?;
|
||||
|
||||
Ok(EpisodeInfo {
|
||||
series_name: best.name,
|
||||
episode_title: found.title,
|
||||
air_date: found.air_date,
|
||||
})
|
||||
}
|
||||
|
||||
/// Searches for a movie by title. OMDb's search endpoint doesn't take a release-year
|
||||
/// filter the way TMDB's does, so `year` is accepted for signature parity with
|
||||
/// `TmdbClient::search_movie` (name-similarity disambiguation alone is what actually
|
||||
/// picks the best result) - OMDb does support a `y=` search parameter, so it's used
|
||||
/// here to narrow the search server-side when a year is known, same intent as TMDB's
|
||||
/// `primary_release_year`.
|
||||
pub async fn search_movie(&self, query: &str, year: Option<i32>) -> Result<Vec<MovieResult>, OmdbError> {
|
||||
let year_str;
|
||||
let mut params: Vec<(&str, &str)> = vec![("s", query), ("type", "movie")];
|
||||
if let Some(y) = year {
|
||||
year_str = y.to_string();
|
||||
params.push(("y", &year_str));
|
||||
}
|
||||
let parsed: SearchResponse = self.get_json(¶ms).await?;
|
||||
Ok(parsed
|
||||
.search
|
||||
.into_iter()
|
||||
.map(|h| MovieResult { imdb_id: h.imdb_id, title: h.title })
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Convenience: search for the best-matching movie by title (and optional release
|
||||
/// year), then fetch its full detail record for a real release date - OMDb's search
|
||||
/// results only carry a bare `Year`, not a full date, unlike TMDB's search results.
|
||||
pub async fn lookup_movie(&self, query: &str, year: Option<i32>) -> Result<MovieInfo, OmdbError> {
|
||||
let results = self.search_movie(query, year).await?;
|
||||
let best = best_title_match(query, &results, |r| r.title.as_str(), NAME_MATCH_THRESHOLD)
|
||||
.ok_or_else(|| OmdbError::NotFound(query.to_string()))?;
|
||||
|
||||
let detail: DetailResponse = self.get_json(&[("i", best.imdb_id.as_str())]).await?;
|
||||
Ok(MovieInfo {
|
||||
title: best.title.clone(),
|
||||
release_date: parse_release_date(&detail.released),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_omdb_style_dates_into_iso_form() {
|
||||
assert_eq!(parse_release_date("16 Jul 2010"), Some("2010-07-16".to_string()));
|
||||
assert_eq!(parse_release_date("01 Jan 2008"), Some("2008-01-01".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn treats_na_and_empty_dates_as_unknown() {
|
||||
assert_eq!(parse_release_date("N/A"), None);
|
||||
assert_eq!(parse_release_date("n/a"), None);
|
||||
assert_eq!(parse_release_date(""), None);
|
||||
assert_eq!(parse_release_date("not a date"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn looks_up_a_real_episode_end_to_end() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _search_mock = server
|
||||
.mock("GET", "/")
|
||||
.match_query(mockito::Matcher::AllOf(vec![
|
||||
mockito::Matcher::UrlEncoded("s".into(), "Girls".into()),
|
||||
mockito::Matcher::UrlEncoded("type".into(), "series".into()),
|
||||
]))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"Search":[{"Title":"Girls","imdbID":"tt1723816"}],"Response":"True"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let _season_mock = server
|
||||
.mock("GET", "/")
|
||||
.match_query(mockito::Matcher::AllOf(vec![
|
||||
mockito::Matcher::UrlEncoded("i".into(), "tt1723816".into()),
|
||||
mockito::Matcher::UrlEncoded("Season".into(), "2".into()),
|
||||
]))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"Episodes":[
|
||||
{"Title":"It's About Time","Episode":"1","Released":"13 Jan 2013"},
|
||||
{"Title":"I Get Ideas","Episode":"2","Released":"20 Jan 2013"}
|
||||
],"Response":"True"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = OmdbClient::new("test-key").with_base_url(server.url());
|
||||
let info = client.lookup_episode("Girls", 2, 2).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
info,
|
||||
EpisodeInfo {
|
||||
series_name: "Girls".to_string(),
|
||||
episode_title: "I Get Ideas".to_string(),
|
||||
air_date: Some("2013-01-20".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reports_not_found_when_search_has_no_results() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _mock = server
|
||||
.mock("GET", "/")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"Response":"False","Error":"Series not found!"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = OmdbClient::new("test-key").with_base_url(server.url());
|
||||
let result = client.lookup_episode("Some Nonexistent Show", 1, 1).await;
|
||||
|
||||
assert!(matches!(result, Err(OmdbError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reports_not_found_when_the_season_has_no_such_episode() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _search_mock = server
|
||||
.mock("GET", "/")
|
||||
.match_query(mockito::Matcher::UrlEncoded("s".into(), "Some Show".into()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"Search":[{"Title":"Some Show","imdbID":"tt0000042"}],"Response":"True"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let _season_mock = server
|
||||
.mock("GET", "/")
|
||||
.match_query(mockito::Matcher::UrlEncoded("i".into(), "tt0000042".into()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"Episodes":[{"Title":"Pilot","Episode":"1","Released":"01 Jan 2020"}],"Response":"True"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = OmdbClient::new("test-key").with_base_url(server.url());
|
||||
// Season 1 only has episode 1 in this mock - asking for episode 99 should miss.
|
||||
let result = client.lookup_episode("Some Show", 1, 99).await;
|
||||
|
||||
assert!(matches!(result, Err(OmdbError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn picks_the_exact_name_match_over_a_more_popular_but_wrong_result() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _search_mock = server
|
||||
.mock("GET", "/")
|
||||
.match_query(mockito::Matcher::UrlEncoded("s".into(), "Chuck".into()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"Search":[
|
||||
{"Title":"Chucky","imdbID":"tt1"},
|
||||
{"Title":"Chuck","imdbID":"tt2"}
|
||||
],"Response":"True"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let _season_mock = server
|
||||
.mock("GET", "/")
|
||||
.match_query(mockito::Matcher::UrlEncoded("i".into(), "tt2".into()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"Episodes":[{"Title":"Chuck Versus the Intersect","Episode":"1","Released":"24 Sep 2007"}],"Response":"True"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = OmdbClient::new("test-key").with_base_url(server.url());
|
||||
let info = client.lookup_episode("Chuck", 1, 1).await.unwrap();
|
||||
|
||||
assert_eq!(info.series_name, "Chuck");
|
||||
assert_eq!(info.episode_title, "Chuck Versus the Intersect");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reports_not_found_when_no_search_result_is_a_plausible_name_match() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _mock = server
|
||||
.mock("GET", "/")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"Search":[{"Title":"Totally Unrelated Program","imdbID":"tt9"}],"Response":"True"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = OmdbClient::new("test-key").with_base_url(server.url());
|
||||
let result = client.lookup_episode("Zzyzx Nonexistent Show", 1, 1).await;
|
||||
|
||||
assert!(matches!(result, Err(OmdbError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn episodes_with_an_unparseable_episode_number_are_skipped_rather_than_panicking() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _search_mock = server
|
||||
.mock("GET", "/")
|
||||
.match_query(mockito::Matcher::UrlEncoded("s".into(), "Some Show".into()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"Search":[{"Title":"Some Show","imdbID":"tt7"}],"Response":"True"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let _season_mock = server
|
||||
.mock("GET", "/")
|
||||
.match_query(mockito::Matcher::UrlEncoded("i".into(), "tt7".into()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"Episodes":[
|
||||
{"Title":"Uncategorized Special","Episode":"N/A","Released":"N/A"},
|
||||
{"Title":"Pilot","Episode":"1","Released":"01 Jan 2021"}
|
||||
],"Response":"True"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = OmdbClient::new("test-key").with_base_url(server.url());
|
||||
let info = client.lookup_episode("Some Show", 1, 1).await.unwrap();
|
||||
|
||||
assert_eq!(info.episode_title, "Pilot");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn looks_up_a_real_movie_end_to_end() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _search_mock = server
|
||||
.mock("GET", "/")
|
||||
.match_query(mockito::Matcher::AllOf(vec![
|
||||
mockito::Matcher::UrlEncoded("s".into(), "Inception".into()),
|
||||
mockito::Matcher::UrlEncoded("type".into(), "movie".into()),
|
||||
mockito::Matcher::UrlEncoded("y".into(), "2010".into()),
|
||||
]))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"Search":[{"Title":"Inception","imdbID":"tt1375666"}],"Response":"True"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let _detail_mock = server
|
||||
.mock("GET", "/")
|
||||
.match_query(mockito::Matcher::UrlEncoded("i".into(), "tt1375666".into()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"Title":"Inception","Released":"16 Jul 2010","Response":"True"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = OmdbClient::new("test-key").with_base_url(server.url());
|
||||
let info = client.lookup_movie("Inception", Some(2010)).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
info,
|
||||
MovieInfo {
|
||||
title: "Inception".to_string(),
|
||||
release_date: Some("2010-07-16".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn picks_the_exact_movie_title_match_over_a_more_popular_but_wrong_result() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _search_mock = server
|
||||
.mock("GET", "/")
|
||||
.match_query(mockito::Matcher::UrlEncoded("s".into(), "Up".into()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"Search":[
|
||||
{"Title":"Wake Up","imdbID":"tt1"},
|
||||
{"Title":"Up","imdbID":"tt2"}
|
||||
],"Response":"True"}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let _detail_mock = server
|
||||
.mock("GET", "/")
|
||||
.match_query(mockito::Matcher::UrlEncoded("i".into(), "tt2".into()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"Title":"Up","Released":"29 May 2009","Response":"True"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = OmdbClient::new("test-key").with_base_url(server.url());
|
||||
let info = client.lookup_movie("Up", None).await.unwrap();
|
||||
|
||||
assert_eq!(info.title, "Up");
|
||||
assert_eq!(info.release_date, Some("2009-05-29".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reports_not_found_when_movie_search_has_no_results() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _mock = server
|
||||
.mock("GET", "/")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"Response":"False","Error":"Movie not found!"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = OmdbClient::new("test-key").with_base_url(server.url());
|
||||
let result = client.lookup_movie("Some Nonexistent Movie", None).await;
|
||||
|
||||
assert!(matches!(result, Err(OmdbError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn surfaces_a_server_error_as_an_api_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _mock = server
|
||||
.mock("GET", "/")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(503)
|
||||
.with_header("content-type", "text/plain")
|
||||
.with_body("service unavailable")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = OmdbClient::new("test-key").with_base_url(server.url());
|
||||
let result = client.search_tv("anything").await;
|
||||
|
||||
match result {
|
||||
Err(OmdbError::Api { status, .. }) => assert_eq!(status, 503),
|
||||
other => panic!("expected Api error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+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,
|
||||
|
||||
+129
-36
@@ -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 client = match settings::movie_metadata_source(&app) {
|
||||
MovieMetadataSource::Tmdb => {
|
||||
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();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,6 +530,14 @@
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.modal-inline-remove {
|
||||
width: auto;
|
||||
align-self: flex-start;
|
||||
margin: 6px 0 4px;
|
||||
padding: 4px 12px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.history-batch-list {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
+176
-41
@@ -106,9 +106,15 @@ type ConflictPolicy = "skip" | "autoNumber" | "override";
|
||||
|
||||
// Mirrors aurora-curator-core's `TvMetadataSource` - which online source Fetch Data
|
||||
// resolves TV episodes against. "tmdb" is the default (matches Aurora Curator's original and
|
||||
// only source before TVmaze existed); "tvmaze" needs no API token at all. Movies always
|
||||
// use TMDB regardless of this setting - TVmaze has no movie catalog to pick between.
|
||||
type TvMetadataSource = "tmdb" | "tvmaze";
|
||||
// only source before TVmaze existed); "tvmaze" needs no API token at all; "omdb" needs
|
||||
// its own separate API key (see MovieMetadataSource below - the two settings are
|
||||
// independent, since OMDb covers both TV and movies but TVmaze only covers TV).
|
||||
type TvMetadataSource = "tmdb" | "tvmaze" | "omdb";
|
||||
|
||||
// Mirrors aurora-curator-core's `MovieMetadataSource` - which online source Fetch Data
|
||||
// resolves movies against. Unlike TV there's no zero-setup option (TVmaze has no movie
|
||||
// catalog) - both choices need their own saved credential.
|
||||
type MovieMetadataSource = "tmdb" | "omdb";
|
||||
|
||||
// Sent to `fetch_metadata`: one file's local guess (from Match) to resolve against
|
||||
// real online TV metadata (TMDB or TVmaze, per the stored TvMetadataSource). Needs a
|
||||
@@ -324,12 +330,29 @@ const CONFLICT_POLICY_LABELS: Record<ConflictPolicy, { button: string; descripti
|
||||
const TV_SOURCE_LABELS: Record<TvMetadataSource, { button: string; description: string }> = {
|
||||
tmdb: {
|
||||
button: "TMDB",
|
||||
description: "Uses TheMovieDB for TV episode titles and air dates — needs the API token below.",
|
||||
description: "Uses TheMovieDB for TV episode titles and air dates — needs the TMDB API token below.",
|
||||
},
|
||||
tvmaze: {
|
||||
button: "TVmaze",
|
||||
description: "Uses TVmaze for TV episode titles and air dates — free, no API token needed at all.",
|
||||
},
|
||||
omdb: {
|
||||
button: "OMDb",
|
||||
description: "Uses OMDb for TV episode titles and air dates — needs the OMDb API key below.",
|
||||
},
|
||||
};
|
||||
|
||||
// Copy for each MovieMetadataSource, used in the Fetch Data Settings modal's own
|
||||
// movie-source segmented selector and its description note. Mirrors TV_SOURCE_LABELS.
|
||||
const MOVIE_SOURCE_LABELS: Record<MovieMetadataSource, { button: string; description: string }> = {
|
||||
tmdb: {
|
||||
button: "TMDB",
|
||||
description: "Uses TheMovieDB for movie titles and release dates — needs the TMDB API token below.",
|
||||
},
|
||||
omdb: {
|
||||
button: "OMDb",
|
||||
description: "Uses OMDb for movie titles and release dates — needs the OMDb API key below.",
|
||||
},
|
||||
};
|
||||
|
||||
function realFileToItem(entry: FileEntry): Item {
|
||||
@@ -425,12 +448,19 @@ export default function RenamePanel() {
|
||||
// nothing saved, `true` = a token is on file (the value itself never comes back to
|
||||
// the frontend after saving - see Settings modal below).
|
||||
const [hasTmdbToken, setHasTmdbToken] = useState<boolean | null>(null);
|
||||
// Same idea as hasTmdbToken, but for the OMDb API key - the two credentials are
|
||||
// entirely independent, since either source can be picked for TV, movies, or both.
|
||||
const [hasOmdbKey, setHasOmdbKey] = useState<boolean | null>(null);
|
||||
// Which online source Fetch Data uses for TV episodes - persisted on disk (see
|
||||
// `src-tauri/src/settings.rs`), defaults to "tmdb" until loaded. Movies always use
|
||||
// TMDB regardless of this, so it only ever gates the TV half of Fetch Data.
|
||||
// `src-tauri/src/settings.rs`), defaults to "tmdb" until loaded.
|
||||
const [tvSource, setTvSource] = useState<TvMetadataSource>("tmdb");
|
||||
// Which online source Fetch Data uses for movies - persisted separately from
|
||||
// tvSource (see `src-tauri/src/settings.rs`'s MovieMetadataSource), defaults to
|
||||
// "tmdb" until loaded. TVmaze isn't an option here - it has no movie catalog.
|
||||
const [movieSource, setMovieSource] = useState<MovieMetadataSource>("tmdb");
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [tmdbTokenInput, setTmdbTokenInput] = useState("");
|
||||
const [omdbKeyInput, setOmdbKeyInput] = useState("");
|
||||
const [savingSettings, setSavingSettings] = useState(false);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
// Rename history / undo. `null` batches = not loaded yet (also used to show a
|
||||
@@ -462,12 +492,24 @@ export default function RenamePanel() {
|
||||
.catch(() => setHasTmdbToken(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
invoke<string | null>("get_omdb_api_key")
|
||||
.then((key) => setHasOmdbKey(!!key))
|
||||
.catch(() => setHasOmdbKey(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
invoke<TvMetadataSource>("get_tv_metadata_source")
|
||||
.then(setTvSource)
|
||||
.catch(() => setTvSource("tmdb"));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
invoke<MovieMetadataSource>("get_movie_metadata_source")
|
||||
.then(setMovieSource)
|
||||
.catch(() => setMovieSource("tmdb"));
|
||||
}, []);
|
||||
|
||||
// Refs so the drag-and-drop listener (registered once on mount) always sees the
|
||||
// latest expression/items instead of the stale values from its first render.
|
||||
const expressionRef = useRef(expression);
|
||||
@@ -907,27 +949,40 @@ export default function RenamePanel() {
|
||||
|
||||
const openSettings = () => {
|
||||
setTmdbTokenInput("");
|
||||
setOmdbKeyInput("");
|
||||
setSettingsOpen(true);
|
||||
};
|
||||
|
||||
const saveTmdbToken = async () => {
|
||||
const trimmed = tmdbTokenInput.trim();
|
||||
if (!trimmed) {
|
||||
// Saves whichever credential field(s) the user actually typed into, then closes the
|
||||
// modal - a single Save button covers both the TMDB token and the OMDb key so
|
||||
// filling in one (or both) at once only takes one click, mirroring how the two
|
||||
// segmented source pickers above it already sit side by side in the same modal.
|
||||
const saveCredentials = async () => {
|
||||
const trimmedTmdb = tmdbTokenInput.trim();
|
||||
const trimmedOmdb = omdbKeyInput.trim();
|
||||
if (!trimmedTmdb && !trimmedOmdb) {
|
||||
// Nothing typed - just close, leaving whatever was already saved untouched.
|
||||
setSettingsOpen(false);
|
||||
return;
|
||||
}
|
||||
setSavingSettings(true);
|
||||
try {
|
||||
await invoke("save_tmdb_token", { token: trimmed });
|
||||
if (trimmedTmdb) {
|
||||
await invoke("save_tmdb_token", { token: trimmedTmdb });
|
||||
setHasTmdbToken(true);
|
||||
}
|
||||
if (trimmedOmdb) {
|
||||
await invoke("save_omdb_api_key", { key: trimmedOmdb });
|
||||
setHasOmdbKey(true);
|
||||
}
|
||||
setTmdbTokenInput("");
|
||||
setOmdbKeyInput("");
|
||||
setSettingsOpen(false);
|
||||
setStatus("TMDB token saved.");
|
||||
window.clearTimeout((saveTmdbToken as any)._t);
|
||||
(saveTmdbToken as any)._t = window.setTimeout(() => setStatus(null), 3000);
|
||||
setStatus("Fetch Data settings saved.");
|
||||
window.clearTimeout((saveCredentials as any)._t);
|
||||
(saveCredentials as any)._t = window.setTimeout(() => setStatus(null), 3000);
|
||||
} catch (err) {
|
||||
setStatus(`Couldn't save the token: ${err}`);
|
||||
setStatus(`Couldn't save: ${err}`);
|
||||
} finally {
|
||||
setSavingSettings(false);
|
||||
}
|
||||
@@ -964,12 +1019,42 @@ export default function RenamePanel() {
|
||||
}
|
||||
};
|
||||
|
||||
// Same idea as saveTvSource, for the movie-source picker.
|
||||
const saveMovieSource = async (source: MovieMetadataSource) => {
|
||||
setMovieSource(source);
|
||||
try {
|
||||
await invoke("save_movie_metadata_source", { source });
|
||||
} catch (err) {
|
||||
setStatus(`Couldn't save the movie source setting: ${err}`);
|
||||
window.clearTimeout((saveMovieSource as any)._t);
|
||||
(saveMovieSource as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
||||
}
|
||||
};
|
||||
|
||||
const clearOmdbApiKey = async () => {
|
||||
setSavingSettings(true);
|
||||
try {
|
||||
await invoke("save_omdb_api_key", { key: "" });
|
||||
setHasOmdbKey(false);
|
||||
setOmdbKeyInput("");
|
||||
setStatus("OMDb API key removed.");
|
||||
window.clearTimeout((clearOmdbApiKey as any)._t);
|
||||
(clearOmdbApiKey as any)._t = window.setTimeout(() => setStatus(null), 3000);
|
||||
} catch (err) {
|
||||
setStatus(`Couldn't remove the key: ${err}`);
|
||||
} finally {
|
||||
setSavingSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
// "Fetch Data": resolve each already-Matched file's local guess against real online
|
||||
// metadata. Requires Match to have already populated either a series name + season/
|
||||
// episode (TV) or a movie title (movie) - that's the search query - so this is
|
||||
// deliberately layered on top of local matching, not a replacement for it. TV items
|
||||
// resolve against whichever source `tvSource` currently picks (TMDB or TVmaze); movie
|
||||
// items always resolve against TMDB. Both run via separate Tauri commands,
|
||||
// resolve against whichever source `tvSource` currently picks (TMDB, TVmaze, or
|
||||
// OMDb); movie items resolve against whichever source `movieSource` picks (TMDB or
|
||||
// OMDb) - the two settings are independent, so a batch mixing TV and movies can even
|
||||
// use different sources for each half. Both run via separate Tauri commands,
|
||||
// concurrently, and merge back into a single set of counts/status message.
|
||||
const runFetch = async () => {
|
||||
const tvTargets = items.filter(
|
||||
@@ -985,12 +1070,19 @@ export default function RenamePanel() {
|
||||
return;
|
||||
}
|
||||
|
||||
// TVmaze needs no token at all, so a TMDB token is only actually required when
|
||||
// there are movie targets (always TMDB) or TV targets while TMDB is the chosen
|
||||
// source - a TV-only batch on TVmaze can run with no TMDB token configured at all.
|
||||
const needsTmdbToken = movieTargets.length > 0 || (tvTargets.length > 0 && tvSource === "tmdb");
|
||||
if (needsTmdbToken && hasTmdbToken === false) {
|
||||
setStatus("Add a free TMDB API token in Settings first, then Fetch Data will work.");
|
||||
// TVmaze needs no credential at all, so TMDB/OMDb are only actually required when
|
||||
// this batch's TV or movie targets are actually pointed at that source - a TV-only
|
||||
// batch on TVmaze can run with neither credential configured at all.
|
||||
const needsTmdbToken =
|
||||
(movieTargets.length > 0 && movieSource === "tmdb") || (tvTargets.length > 0 && tvSource === "tmdb");
|
||||
const needsOmdbKey =
|
||||
(movieTargets.length > 0 && movieSource === "omdb") || (tvTargets.length > 0 && tvSource === "omdb");
|
||||
if ((needsTmdbToken && hasTmdbToken === false) || (needsOmdbKey && hasOmdbKey === false)) {
|
||||
const missing = [
|
||||
needsTmdbToken && hasTmdbToken === false ? "a free TMDB API token" : null,
|
||||
needsOmdbKey && hasOmdbKey === false ? "a free OMDb API key" : null,
|
||||
].filter((m): m is string => m !== null);
|
||||
setStatus(`Add ${missing.join(" and ")} in Settings first, then Fetch Data will work.`);
|
||||
setSettingsOpen(true);
|
||||
window.clearTimeout((runFetch as any)._t);
|
||||
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
||||
@@ -1363,10 +1455,10 @@ export default function RenamePanel() {
|
||||
needs a network call, since it's all parsed straight from the filename itself (Aurora Curator has no video/audio
|
||||
file prober, so a tag that isn't in the filename text just won't be available). Fetch Data resolves the
|
||||
season/episode or movie guess against real online metadata for the real title, episode title, and
|
||||
air/release date. TV episodes can come from either TMDB or TVmaze — pick which in{" "}
|
||||
<strong>Fetch Data Settings</strong> (TVmaze is free and needs no token at all); movies always use TMDB
|
||||
and need a free API token set there first. A batch can
|
||||
freely mix TV and movie files — each file only fills in the fields that apply to it. FileBot's own short
|
||||
air/release date. TV episodes can come from TMDB, TVmaze, or OMDb, and movies from TMDB or OMDb — pick
|
||||
each independently in <strong>Fetch Data Settings</strong> (TVmaze is free and needs no credential at
|
||||
all; TMDB and OMDb each need their own free API credential set there first). A batch can
|
||||
freely mix TV and movie files, even against different sources — each file only fills in the fields that apply to it. FileBot's own short
|
||||
field names (<code>{"{n}"}</code>, <code>{"{s00e00}"}</code>, <code>{"{t}"}</code>, <code>{"{y}"}</code>,
|
||||
<code>{"{vf}"}</code>, <code>{"{vc}"}</code>, <code>{"{ac}"}</code>, <code>{"{group}"}</code>, etc.) still
|
||||
work too, side by side with the names above.
|
||||
@@ -1486,7 +1578,7 @@ export default function RenamePanel() {
|
||||
<h2 className="modal-title">Fetch Data Settings</h2>
|
||||
<span className="modal-field-label">TV episode source</span>
|
||||
<div className="action-segmented" role="radiogroup" aria-label="Which source Fetch Data uses for TV episodes">
|
||||
{(["tmdb", "tvmaze"] as const).map((s) => (
|
||||
{(["tmdb", "tvmaze", "omdb"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
@@ -1498,17 +1590,38 @@ export default function RenamePanel() {
|
||||
))}
|
||||
</div>
|
||||
<p className="modal-subtitle">{TV_SOURCE_LABELS[tvSource].description}</p>
|
||||
|
||||
<span className="modal-field-label">Movie source</span>
|
||||
<div className="action-segmented" role="radiogroup" aria-label="Which source Fetch Data uses for movies">
|
||||
{(["tmdb", "omdb"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={`action-segmented-option${movieSource === s ? " selected" : ""}`}
|
||||
onClick={() => saveMovieSource(s)}
|
||||
>
|
||||
{MOVIE_SOURCE_LABELS[s].button}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="modal-subtitle">
|
||||
Movies always resolve against{" "}
|
||||
{MOVIE_SOURCE_LABELS[movieSource].description} TVmaze isn't an option here — it has no movie catalog.
|
||||
</p>
|
||||
|
||||
<p className="modal-subtitle">
|
||||
Get a free <strong>API Read Access Token</strong> from your{" "}
|
||||
<a href="https://www.themoviedb.org/" target="_blank" rel="noreferrer">
|
||||
TheMovieDB
|
||||
TMDB
|
||||
</a>{" "}
|
||||
regardless of the TV source above — TVmaze has no movie catalog. Get a free{" "}
|
||||
<strong>API Read Access Token</strong> from your TMDB account under Settings → API, then paste it below.
|
||||
The token is stored locally on this machine and never leaves it except to call TMDB directly.
|
||||
account under Settings → API, and/or a free API key from{" "}
|
||||
<a href="https://www.omdbapi.com/apikey.aspx" target="_blank" rel="noreferrer">
|
||||
OMDb
|
||||
</a>
|
||||
, then paste whichever one(s) you need below. Each credential is stored locally on this machine and
|
||||
never leaves it except to call that source directly.
|
||||
</p>
|
||||
<label htmlFor="tmdb-token" className="modal-field-label">
|
||||
API Read Access Token
|
||||
TMDB API Read Access Token
|
||||
</label>
|
||||
<input
|
||||
id="tmdb-token"
|
||||
@@ -1520,25 +1633,47 @@ export default function RenamePanel() {
|
||||
onChange={(e) => setTmdbTokenInput(e.target.value)}
|
||||
placeholder={hasTmdbToken ? "•••••••••• (already saved — enter a new token to replace it)" : "Paste your TMDB token here"}
|
||||
/>
|
||||
<div className="modal-actions modal-actions-split">
|
||||
{hasTmdbToken ? (
|
||||
<button className="action-button" onClick={clearTmdbToken} disabled={savingSettings}>
|
||||
Remove Token
|
||||
{hasTmdbToken && (
|
||||
<button
|
||||
className="action-button modal-inline-remove"
|
||||
onClick={clearTmdbToken}
|
||||
disabled={savingSettings}
|
||||
>
|
||||
Remove TMDB Token
|
||||
</button>
|
||||
)}
|
||||
<label htmlFor="omdb-key" className="modal-field-label">
|
||||
OMDb API Key
|
||||
</label>
|
||||
<input
|
||||
id="omdb-key"
|
||||
className="modal-text-input"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={omdbKeyInput}
|
||||
onChange={(e) => setOmdbKeyInput(e.target.value)}
|
||||
placeholder={hasOmdbKey ? "•••••••••• (already saved — enter a new key to replace it)" : "Paste your OMDb key here"}
|
||||
/>
|
||||
{hasOmdbKey && (
|
||||
<button
|
||||
className="action-button modal-inline-remove"
|
||||
onClick={clearOmdbApiKey}
|
||||
disabled={savingSettings}
|
||||
>
|
||||
Remove OMDb Key
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="modal-actions">
|
||||
<button className="action-button" onClick={() => setSettingsOpen(false)} disabled={savingSettings}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="action-button primary" onClick={saveTmdbToken} disabled={savingSettings}>
|
||||
<button className="action-button primary" onClick={saveCredentials} disabled={savingSettings}>
|
||||
{savingSettings ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{historyOpen && (
|
||||
|
||||
Reference in New Issue
Block a user