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"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aurora-curator-core",
|
"aurora-curator-core",
|
||||||
|
"dirs",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"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
|
movie title/year* detection ("Match") is working, real online metadata lookups
|
||||||
("Fetch Data") are working for both TV episodes and movies — including fuzzy title
|
("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
|
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
|
take the source's top-ranked hit. TV episodes can resolve against TMDB, TVmaze, or
|
||||||
TVmaze — user-selectable in Fetch Data Settings, TMDB stays the default and TVmaze
|
OMDb, and movies against TMDB or OMDb — each picked independently in Fetch Data
|
||||||
needs no API token at all — while movies always resolve against TMDB, since TVmaze has
|
Settings, TMDB stays the default for both, TVmaze needs no API token at all (it has no
|
||||||
no movie catalog. The actual on-disk operation is wired up (with a
|
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,
|
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/
|
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/
|
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/
|
the same module (`guess_release_info` — resolution, source, video/
|
||||||
audio codec, release group), text-similarity scoring for
|
audio codec, release group), text-similarity scoring for
|
||||||
disambiguating search results (`similarity/`, partial
|
disambiguating search results (`similarity/`, partial
|
||||||
`EpisodeMetrics` port), two web metadata clients — `web/tmdb.rs`
|
`EpisodeMetrics` port), three web metadata clients — `web/tmdb.rs`
|
||||||
(TV search + episode lookup and movie search + lookup) and
|
(TV search + episode lookup and movie search + lookup),
|
||||||
`web/tvmaze.rs` (TV search + episode lookup only — TVmaze has no
|
`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
|
`similarity/` and tested against a mocked HTTP server via
|
||||||
`mockito`, the on-disk
|
`mockito`, the on-disk
|
||||||
rename engine (`rename/` — Move/Copy/Symlink actions,
|
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
|
argument parsing/file expansion (`cli/` — no Tauri or network
|
||||||
dependency at all, see "Command-line usage" below). Not yet
|
dependency at all, see "Command-line usage" below). Not yet
|
||||||
ported: full media metadata bindings, other web datasource
|
ported: full media metadata bindings, other web datasource
|
||||||
clients (TheTVDB/AniDB/OMDb/etc). No Tauri dependency, so it
|
clients (TheTVDB/AniDB/OpenSubtitles/etc). No Tauri dependency, so
|
||||||
builds and unit-tests standalone.
|
it builds and unit-tests standalone.
|
||||||
src-tauri/ Tauri app shell: exposes aurora-curator-core as commands to the frontend
|
src-tauri/ Tauri app shell: exposes aurora-curator-core as commands to the frontend
|
||||||
(naming-scheme rendering, filename matching for both TV and
|
(naming-scheme rendering, filename matching for both TV and
|
||||||
movies, metadata fetch — `metadata.rs` for TV episodes (against
|
movies, metadata fetch — `metadata.rs` for TV episodes (against
|
||||||
whichever of TMDB/TVmaze is the stored source) plus a
|
whichever of TMDB/TVmaze/OMDb is the stored TV source) plus a
|
||||||
movie-fetch command alongside it (always TMDB) — executing a rename
|
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
|
plan, native file/folder dialogs, drag-and-drop expansion), plus
|
||||||
`settings.rs` (TMDB token, and which TV metadata source Fetch Data
|
`settings.rs` (TMDB token, OMDb API key, and which TV *and* movie
|
||||||
uses), `history.rs` (rename history/undo
|
metadata source Fetch Data uses — two independent settings),
|
||||||
|
`history.rs` (rename history/undo
|
||||||
log), `presets.rs` (up to 9 saved naming schemes) — all three
|
log), `presets.rs` (up to 9 saved naming schemes) — all three
|
||||||
stored as JSON in the OS app-config directory — `cli.rs` (the
|
stored as JSON in the OS app-config directory — `cli.rs` (the
|
||||||
`-rename` command-line mode's orchestration: builds and executes a
|
`-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,
|
two-pane rename screen (Aurora theme) wired to real file loading,
|
||||||
drag-and-drop, live naming-scheme preview, local TV/movie
|
drag-and-drop, live naming-scheme preview, local TV/movie
|
||||||
matching, metadata fetch for both (behind a Fetch Data Settings
|
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
|
confirm-before-touching-disk modal for the actual
|
||||||
rename, a History panel that can undo a past rename, Align Up/Down
|
rename, a History panel that can undo a past rename, Align Up/Down
|
||||||
list reordering, and a Presets panel (plus `1`-`9` hotkeys) for
|
list reordering, and a Presets panel (plus `1`-`9` hotkeys) for
|
||||||
@@ -200,13 +209,13 @@ npm run tauri dev
|
|||||||
npm run tauri build
|
npm run tauri build
|
||||||
```
|
```
|
||||||
|
|
||||||
Verified in the environment this was built in: `cargo test -p aurora-curator-core` (all
|
Verified in the environment this was built in: `cargo test --workspace` (all 128
|
||||||
passing, including the TMDB and TVmaze client tests — those run against a local mock
|
passing, including the TMDB, TVmaze, and OMDb client tests — those run against a local
|
||||||
HTTP server via `mockito`, no live network or API key needed to build/test), `cargo check -p
|
mock HTTP server via `mockito`, no live network or API key needed to build/test), `cargo
|
||||||
aurora-curator` (compiles clean against real Tauri + GTK/WebKit system libraries), and `npm run
|
check -p aurora-curator` (compiles clean against real Tauri + GTK/WebKit system
|
||||||
build` (TypeScript type-checks, Vite bundles). The actual GUI window has **not** been
|
libraries), and `npm run build` (TypeScript type-checks, Vite bundles). The actual GUI
|
||||||
visually verified anywhere yet — that sandbox has no display. Run `npm run tauri dev`
|
window has **not** been visually verified anywhere yet — that sandbox has no display.
|
||||||
on your machine to see and click through it for the first time.
|
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")
|
### 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
|
talks to the network, and it's the only one that needs any setup at all — and even that
|
||||||
setup is optional for TV.
|
setup is optional for TV.
|
||||||
|
|
||||||
TV episodes can resolve against either of two sources, picked in the app (click the key
|
TV episodes and movies each have their own independent source picker in the app (click
|
||||||
icon, "Fetch Data Settings", next to Fetch Data):
|
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.
|
- **[TVmaze](https://www.tvmaze.com/)** — TV only (it has no movie catalog). Free, no
|
||||||
Just pick it and go.
|
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
|
- **[TMDB](https://www.themoviedb.org/)** — the default for both TV and movies. Needs a
|
||||||
(TVmaze has no movie catalog). Needs a free account:
|
free account:
|
||||||
1. Create a TMDB account, then go to Settings → API in your account and generate an
|
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
|
**API Read Access Token** (not the older numeric "API Key" — Aurora Curator uses the
|
||||||
Bearer token method TMDB documents as the default).
|
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
|
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`.
|
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
|
A batch of TV-only files can run entirely on TVmaze with no credential configured at
|
||||||
batch containing any movies still needs the TMDB token regardless of which TV source is
|
all. Otherwise, whichever of TMDB/OMDb is actually selected — for TV, for movies, or
|
||||||
selected.
|
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
|
## 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
|
aliases (`{show name}`, `{episode name}`, `{movie name}`, `{resolution}`, `{video
|
||||||
codec}`, `{release group}`, …), or real Rhai script. `{name}`/`{ext}` fall back to the
|
codec}`, `{release group}`, …), or real Rhai script. `{name}`/`{ext}` fall back to the
|
||||||
file's own current filename/extension when nothing else matched it.
|
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
|
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
|
Match. Movies are also looked up online whenever this is `tmdb` or `omdb` — TVmaze has
|
||||||
catalog, and with no `--db` at all every file uses local filename matching only, same
|
no movie catalog, so `--db tvmaze` only affects the TV half of a batch — and with no
|
||||||
as Match without Fetch Data in the GUI. `--db tmdb` needs a TMDB token that's already
|
`--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
|
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
|
accepts an API token or key as an argument**, so there's nothing secret to leak into
|
||||||
shell history or a script.
|
your shell history or a script.
|
||||||
- `--action <move|copy|symlink|test>` (default `move`) — what to do at each computed
|
- `--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
|
target; `test` prints the full plan and touches nothing on disk, useful for checking a
|
||||||
naming scheme before committing to it.
|
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
|
4. **Media detection** — `net.filebot.media.MediaDetection` (1356 lines): ties file-name
|
||||||
parsing + matching together to auto-identify what a file is.
|
parsing + matching together to auto-identify what a file is.
|
||||||
5. **Web datasource clients** — `net.filebot.web.*` (~40 files): TheMovieDB, TheTVDB,
|
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) —
|
(`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
|
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 —
|
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
|
`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-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
|
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
|
unnumbered/special episodes rather than panicking on them) — and OMDb, for both TV
|
||||||
Data" — a user-selectable "TV episode source" picker in Fetch Data Settings chooses
|
and movies (`crates/aurora-curator-core/src/web/omdb.rs`, same `reqwest` + `serde_json`
|
||||||
which of the two a batch's TV items resolve against (TMDB stays the default; movies
|
shape but a single flat root-URL REST API rather than TMDB's per-resource paths, plain
|
||||||
always use TMDB regardless of that picker) — disambiguating multi-result searches via
|
`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
|
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,
|
any source's top-ranked hit. All other sources (TheTVDB, AniDB, OpenSubtitles) are not
|
||||||
OpenSubtitles) are not yet started.
|
yet started.
|
||||||
6. **Rename engine + CLI** — `net.filebot.cli.CmdlineOperations` (1235 lines) and
|
6. **Rename engine + CLI** — `net.filebot.cli.CmdlineOperations` (1235 lines) and
|
||||||
`ArgumentBean` (415 lines, the full flag surface: `-rename`, `--db`, `--format`,
|
`ArgumentBean` (415 lines, the full flag surface: `-rename`, `--db`, `--format`,
|
||||||
`--action`, `--conflict`, `-get-subtitles`, `-script`, etc.). The rename operation is
|
`--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`/
|
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
|
`-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,
|
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`/
|
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-
|
`-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
|
subtitles` and `-script` have no analog yet since subtitle-fetching and a script-mode
|
||||||
|
|||||||
@@ -24,16 +24,18 @@
|
|||||||
use crate::rename::{ConflictPolicy, RenameAction};
|
use crate::rename::{ConflictPolicy, RenameAction};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// Which online source `--db` should resolve TV episodes against. Deliberately its own
|
/// Which online source `--db` should resolve TV episodes (and, for `Tmdb`/`Omdb`,
|
||||||
/// small enum rather than reusing the Tauri crate's `settings::TvMetadataSource` (this
|
/// movies too - see `src-tauri/src/cli.rs`'s `run_rename`) against. Deliberately its
|
||||||
/// crate can't depend on that crate, and that enum represents a *persisted GUI
|
/// own small enum rather than reusing the Tauri crate's `settings::TvMetadataSource`/
|
||||||
/// preference* anyway, not a per-invocation flag) - a CLI run always states its source
|
/// `MovieMetadataSource` (this crate can't depend on that crate, and those enums
|
||||||
/// explicitly via `--db` when it wants one; it never falls back to whatever the GUI's
|
/// represent *persisted GUI preferences* anyway, not a per-invocation flag) - a CLI run
|
||||||
/// Fetch Data Settings last had selected.
|
/// 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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum TvDbChoice {
|
pub enum TvDbChoice {
|
||||||
Tmdb,
|
Tmdb,
|
||||||
Tvmaze,
|
Tvmaze,
|
||||||
|
Omdb,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A fully parsed, valid `-rename` invocation.
|
/// 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() {
|
db = Some(match value.to_ascii_lowercase().as_str() {
|
||||||
"tmdb" => TvDbChoice::Tmdb,
|
"tmdb" => TvDbChoice::Tmdb,
|
||||||
"tvmaze" => TvDbChoice::Tvmaze,
|
"tvmaze" => TvDbChoice::Tvmaze,
|
||||||
|
"omdb" => TvDbChoice::Omdb,
|
||||||
other => {
|
other => {
|
||||||
return Err(CliError(format!(
|
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:?}"),
|
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(&[
|
let err = parse_args(&[
|
||||||
s("-rename"),
|
s("-rename"),
|
||||||
s("--format"),
|
s("--format"),
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
//! Online metadata clients ("Fetch Data"). TMDB (TV + movies) and TVmaze (TV only, no
|
//! Online metadata clients ("Fetch Data"). TMDB (TV + movies), TVmaze (TV only, no API
|
||||||
//! API key needed) so far; the original FileBot supports ~40 sources
|
//! key needed), and OMDb (TV + movies, needs a free API key) so far; the original
|
||||||
//! (`net.filebot.web.*`) - TMDB was picked first since it's the most widely useful for
|
//! FileBot supports ~40 sources (`net.filebot.web.*`) - TMDB was picked first since
|
||||||
//! both TV and movies, TVmaze second since it's a zero-setup second TV source (see
|
//! it's the most widely useful for both TV and movies, TVmaze second since it's a
|
||||||
//! `tvmaze`'s module doc for why movies aren't in scope for it).
|
//! 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 tmdb;
|
||||||
pub mod tvmaze;
|
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::matching::{self, ReleaseInfo};
|
||||||
use aurora_curator_core::rename::{self, RenameOutcome, RenamePlanItem};
|
use aurora_curator_core::rename::{self, RenameOutcome, RenamePlanItem};
|
||||||
use aurora_curator_core::string_bindings;
|
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::tmdb::TmdbClient;
|
||||||
use aurora_curator_core::web::tvmaze::TvmazeClient;
|
use aurora_curator_core::web::tvmaze::TvmazeClient;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::metadata::AnyTvClient;
|
use crate::metadata::{AnyMovieClient, AnyTvClient, MovieLookup};
|
||||||
use crate::settings;
|
use crate::settings;
|
||||||
|
|
||||||
const USAGE: &str = "\
|
const USAGE: &str = "\
|
||||||
@@ -41,16 +42,16 @@ Usage:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--format <expr> Naming scheme, e.g. \"{show name} - {season number}x{episode number} - {episode name}\" (required)
|
--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;
|
--db <tmdb|tvmaze|omdb> 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)
|
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)
|
--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)
|
--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
|
-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)
|
confidence score to loosen - see the aurora_curator_core::cli module doc)
|
||||||
-h, --help Show this help and exit
|
-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 -
|
A --db tmdb/omdb source needs the matching API token/key already saved through the app's own Fetch Data
|
||||||
the CLI never accepts a token as a command-line argument.
|
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.
|
/// 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
|
// 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
|
// different saved-token states, and so a missing token/key fails fast instead of
|
||||||
// however many files were already processed.
|
// after however many files were already processed.
|
||||||
let tmdb_token = if args.db == Some(TvDbChoice::Tmdb) {
|
let tmdb_token = if args.db == Some(TvDbChoice::Tmdb) {
|
||||||
match settings::cli_tmdb_token() {
|
match settings::cli_tmdb_token() {
|
||||||
Some(token) => Some(token),
|
Some(token) => Some(token),
|
||||||
@@ -108,6 +109,19 @@ fn run_rename(args: RenameCliArgs) -> i32 {
|
|||||||
} else {
|
} else {
|
||||||
None
|
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 {
|
let tv_client = match args.db {
|
||||||
None => None,
|
None => None,
|
||||||
@@ -115,10 +129,22 @@ fn run_rename(args: RenameCliArgs) -> i32 {
|
|||||||
Some(TvDbChoice::Tmdb) => Some(AnyTvClient::Tmdb(TmdbClient::new(
|
Some(TvDbChoice::Tmdb) => Some(AnyTvClient::Tmdb(TmdbClient::new(
|
||||||
tmdb_token.clone().expect("checked above"),
|
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));
|
let items = tauri::async_runtime::block_on(build_plan(&files, &compiled, tv_client, movie_client));
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
@@ -219,7 +245,7 @@ async fn build_plan(
|
|||||||
files: &[PathBuf],
|
files: &[PathBuf],
|
||||||
compiled: &ExpressionFormat,
|
compiled: &ExpressionFormat,
|
||||||
tv_client: Option<AnyTvClient>,
|
tv_client: Option<AnyTvClient>,
|
||||||
movie_client: Option<TmdbClient>,
|
movie_client: Option<AnyMovieClient>,
|
||||||
) -> Vec<RenamePlanItem> {
|
) -> Vec<RenamePlanItem> {
|
||||||
// Cache per (series_query, season), same as the GUI's own `run_fetch` - a whole
|
// 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.
|
// 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 Some(client) = &movie_client {
|
||||||
if let Ok(info) = client.lookup_movie(&movie.title, movie.year).await {
|
if let Ok(MovieLookup::Found { title, release_date }) =
|
||||||
bindings.insert("n".to_string(), info.title.clone());
|
client.lookup_movie(&movie.title, movie.year).await
|
||||||
bindings.insert("movie name".to_string(), info.title.clone());
|
{
|
||||||
if let Some(release_date) = &info.release_date {
|
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());
|
bindings.insert("airdate".to_string(), release_date.clone());
|
||||||
// Only overwrite the locally-guessed year if Match didn't
|
// Only overwrite the locally-guessed year if Match didn't
|
||||||
// already find one in the filename itself - same rule
|
// already find one in the filename itself - same rule
|
||||||
// RenamePanel.tsx's Fetch Data handler uses, since a
|
// RenamePanel.tsx's Fetch Data handler uses, since a
|
||||||
// filename-carried year is usually more precise than TMDB's
|
// filename-carried year is usually more precise than the
|
||||||
// release date for disambiguating remakes.
|
// online source's release date for disambiguating remakes.
|
||||||
if movie.year.is_none() {
|
if movie.year.is_none() {
|
||||||
let year: String = release_date.chars().take(4).collect();
|
let year: String = release_date.chars().take(4).collect();
|
||||||
bindings.insert("y".to_string(), year.clone());
|
bindings.insert("y".to_string(), year.clone());
|
||||||
@@ -492,4 +520,32 @@ mod tests {
|
|||||||
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
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,
|
metadata::fetch_movie_metadata,
|
||||||
settings::get_tmdb_token,
|
settings::get_tmdb_token,
|
||||||
settings::save_tmdb_token,
|
settings::save_tmdb_token,
|
||||||
|
settings::get_omdb_api_key,
|
||||||
|
settings::save_omdb_api_key,
|
||||||
settings::get_tv_metadata_source,
|
settings::get_tv_metadata_source,
|
||||||
settings::save_tv_metadata_source,
|
settings::save_tv_metadata_source,
|
||||||
|
settings::get_movie_metadata_source,
|
||||||
|
settings::save_movie_metadata_source,
|
||||||
history::list_rename_history,
|
history::list_rename_history,
|
||||||
history::undo_rename_batch,
|
history::undo_rename_batch,
|
||||||
presets::list_presets,
|
presets::list_presets,
|
||||||
|
|||||||
+130
-37
@@ -4,18 +4,20 @@
|
|||||||
//! Fetch Data needs a series name and season/episode to search with, which is exactly
|
//! Fetch Data needs a series name and season/episode to search with, which is exactly
|
||||||
//! what Match already produces for free, offline.
|
//! 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
|
//! source` currently has stored (see `AnyTvClient` below), defaulting to TMDB. Movie
|
||||||
//! lookups always go against TMDB regardless of that setting; TVmaze has no movie
|
//! lookups can go against TMDB or OMDb - whichever `settings::movie_metadata_source`
|
||||||
//! catalog to pick between.
|
//! 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::tmdb::TmdbClient;
|
||||||
use aurora_curator_core::web::tvmaze::TvmazeClient;
|
use aurora_curator_core::web::tvmaze::TvmazeClient;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tauri::AppHandle;
|
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.
|
/// One file's local guess, sent up from the UI to be resolved against TMDB.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@@ -72,6 +74,7 @@ pub(crate) type SeasonLookup = Result<(String, Vec<LocalEpisode>), String>;
|
|||||||
pub(crate) enum AnyTvClient {
|
pub(crate) enum AnyTvClient {
|
||||||
Tmdb(TmdbClient),
|
Tmdb(TmdbClient),
|
||||||
Tvmaze(TvmazeClient),
|
Tvmaze(TvmazeClient),
|
||||||
|
Omdb(OmdbClient),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AnyTvClient {
|
impl AnyTvClient {
|
||||||
@@ -81,6 +84,7 @@ impl AnyTvClient {
|
|||||||
match self {
|
match self {
|
||||||
AnyTvClient::Tmdb(_) => "TMDB",
|
AnyTvClient::Tmdb(_) => "TMDB",
|
||||||
AnyTvClient::Tvmaze(_) => "TVmaze",
|
AnyTvClient::Tvmaze(_) => "TVmaze",
|
||||||
|
AnyTvClient::Omdb(_) => "OMDb",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,36 +128,113 @@ impl AnyTvClient {
|
|||||||
.collect(),
|
.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
|
/// 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
|
/// 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
|
/// 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.
|
/// 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
|
/// TVmaze needs no credential at all, so a batch can run against it with nothing
|
||||||
/// against TVmaze even with no TMDB token configured (movies, elsewhere in this file,
|
/// configured; TMDB and OMDb each need their own saved credential before a batch
|
||||||
/// always need the TMDB token regardless of this setting).
|
/// resolved against them will succeed (independently of whichever source movies are
|
||||||
|
/// using, elsewhere in this file).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn fetch_metadata(app: AppHandle, items: Vec<FetchQuery>) -> Vec<FetchResult> {
|
pub async fn fetch_metadata(app: AppHandle, items: Vec<FetchQuery>) -> Vec<FetchResult> {
|
||||||
let client = match settings::tv_metadata_source(&app) {
|
let client = match settings::tv_metadata_source(&app) {
|
||||||
TvMetadataSource::Tmdb => {
|
TvMetadataSource::Tmdb => {
|
||||||
let Some(token) = settings::tmdb_token(&app) else {
|
let Some(token) = settings::tmdb_token(&app) else {
|
||||||
return items
|
return failed_all(items, "No TMDB API token configured — open Settings to add one.");
|
||||||
.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();
|
|
||||||
};
|
};
|
||||||
AnyTvClient::Tmdb(TmdbClient::new(token))
|
AnyTvClient::Tmdb(TmdbClient::new(token))
|
||||||
}
|
}
|
||||||
TvMetadataSource::Tvmaze => AnyTvClient::Tvmaze(TvmazeClient::new()),
|
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))
|
tauri::async_runtime::spawn(run_fetch(client, items))
|
||||||
@@ -226,41 +307,53 @@ pub struct MovieFetchResult {
|
|||||||
pub outcome: MovieFetchOutcome,
|
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
|
/// `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
|
/// 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]
|
#[tauri::command]
|
||||||
pub async fn fetch_movie_metadata(app: AppHandle, items: Vec<MovieFetchQuery>) -> Vec<MovieFetchResult> {
|
pub async fn fetch_movie_metadata(app: AppHandle, items: Vec<MovieFetchQuery>) -> Vec<MovieFetchResult> {
|
||||||
let Some(token) = settings::tmdb_token(&app) else {
|
let client = match settings::movie_metadata_source(&app) {
|
||||||
return items
|
MovieMetadataSource::Tmdb => {
|
||||||
.into_iter()
|
let Some(token) = settings::tmdb_token(&app) else {
|
||||||
.map(|item| MovieFetchResult {
|
return failed_all_movies(items, "No TMDB API token configured — open Settings to add one.");
|
||||||
id: item.id,
|
};
|
||||||
outcome: MovieFetchOutcome::Failed {
|
AnyMovieClient::Tmdb(TmdbClient::new(token))
|
||||||
error: "No TMDB API token configured — open Settings to add one.".to_string(),
|
}
|
||||||
},
|
MovieMetadataSource::Omdb => {
|
||||||
})
|
let Some(key) = settings::omdb_api_key(&app) else {
|
||||||
.collect();
|
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
|
.await
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_movie_fetch(token: String, items: Vec<MovieFetchQuery>) -> Vec<MovieFetchResult> {
|
/// Same idea as `failed_all` above, for the movie side's own result shape.
|
||||||
let client = TmdbClient::new(token);
|
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());
|
let mut results = Vec::with_capacity(items.len());
|
||||||
|
|
||||||
for item in items {
|
for item in items {
|
||||||
let outcome = match client.lookup_movie(&item.title_query, item.year).await {
|
let outcome = match client.lookup_movie(&item.title_query, item.year).await {
|
||||||
Ok(info) => MovieFetchOutcome::Found {
|
Ok(MovieLookup::Found { title, release_date }) => MovieFetchOutcome::Found { title, release_date },
|
||||||
title: info.title,
|
Ok(MovieLookup::NotFound) => MovieFetchOutcome::NotFound,
|
||||||
release_date: info.release_date,
|
Err(error) => MovieFetchOutcome::Failed { error },
|
||||||
},
|
|
||||||
Err(aurora_curator_core::web::tmdb::TmdbError::NotFound(_)) => MovieFetchOutcome::NotFound,
|
|
||||||
Err(error) => MovieFetchOutcome::Failed { error: error.to_string() },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
results.push(MovieFetchResult { id: item.id, outcome });
|
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
|
//! Small persisted app settings - the TMDB API token, the OMDb API key, and which TV
|
||||||
//! was added) which TV metadata source Fetch Data should use ("Fetch Data" is the only
|
//! and movie metadata sources Fetch Data should use ("Fetch Data" is the only thing in
|
||||||
//! thing in the app that needs settings at all; everything else works offline). Stored
|
//! 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
|
//! 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`
|
//! bundle identifier - `~/Library/Application Support/com.auroracurator.app/settings.json`
|
||||||
//! on macOS, `~/.config/com.auroracurator.app/settings.json` on Linux, `%APPDATA%\
|
//! 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`
|
//! The GUI reaches that directory through a running Tauri `AppHandle`
|
||||||
//! (`app.path().app_config_dir()`); the `-rename` CLI mode (`cli.rs`) never builds a
|
//! (`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 -
|
//! 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()
|
//! see [`cli_tmdb_token`]/[`cli_omdb_api_key`] below, which replicate Tauri's own
|
||||||
//! .join(identifier)` computation directly (confirmed against Tauri 2.11's
|
//! `dirs::config_dir().join(identifier)` computation directly (confirmed against Tauri
|
||||||
//! `path/desktop.rs` source) so both code paths are guaranteed to read/write the same
|
//! 2.11's `path/desktop.rs` source) so both code paths are guaranteed to read/write the
|
||||||
//! file. [`APP_CONFIG_IDENTIFIER`] must be kept in sync with `tauri.conf.json`'s
|
//! 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.
|
//! `identifier` field by hand - there's no way to share a constant with the JSON config.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
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.
|
/// Must match `tauri.conf.json`'s `identifier` field exactly - see the module doc.
|
||||||
const APP_CONFIG_IDENTIFIER: &str = "com.auroracurator.app";
|
const APP_CONFIG_IDENTIFIER: &str = "com.auroracurator.app";
|
||||||
|
|
||||||
/// Which online source Fetch Data resolves TV episodes against. Movies always use
|
/// Which online source Fetch Data resolves TV episodes against. TVmaze needs no API
|
||||||
/// TMDB regardless of this setting - TVmaze doesn't have a movie catalog at all, so
|
/// key at all; TMDB and OMDb each need their own saved credential (see
|
||||||
/// there's nothing to pick between for the movie half of Fetch Data.
|
/// [`tmdb_token`]/[`omdb_api_key`]) before a batch resolved against them will succeed.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub enum TvMetadataSource {
|
pub enum TvMetadataSource {
|
||||||
Tmdb,
|
Tmdb,
|
||||||
Tvmaze,
|
Tvmaze,
|
||||||
|
Omdb,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TvMetadataSource {
|
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)]
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
struct StoredSettings {
|
struct StoredSettings {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
tmdb_token: Option<String>,
|
tmdb_token: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
omdb_api_key: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
tv_metadata_source: TvMetadataSource,
|
tv_metadata_source: TvMetadataSource,
|
||||||
|
#[serde(default)]
|
||||||
|
movie_metadata_source: MovieMetadataSource,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
|
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)
|
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).
|
/// Returns the stored TV metadata source (defaults to TMDB if nothing's been chosen).
|
||||||
pub fn tv_metadata_source(app: &AppHandle) -> TvMetadataSource {
|
pub fn tv_metadata_source(app: &AppHandle) -> TvMetadataSource {
|
||||||
load(app).tv_metadata_source
|
load(app).tv_metadata_source
|
||||||
@@ -123,6 +174,24 @@ pub fn save_tv_metadata_source(app: AppHandle, source: TvMetadataSource) -> Resu
|
|||||||
save(&app, &settings)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -134,11 +203,19 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tv_metadata_source_serializes_as_lowercase_camel_case() {
|
fn tv_metadata_source_serializes_as_lowercase_camel_case() {
|
||||||
// The frontend sends/receives this as a plain string ("tmdb"/"tvmaze") - pin
|
// The frontend sends/receives this as a plain string ("tmdb"/"tvmaze"/"omdb") -
|
||||||
// the exact serde output so a future refactor can't silently change the wire
|
// pin the exact serde output so a future refactor can't silently change the
|
||||||
// format out from under the TS side without a test catching it.
|
// 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::Tmdb).unwrap(), "\"tmdb\"");
|
||||||
assert_eq!(serde_json::to_string(&TvMetadataSource::Tvmaze).unwrap(), "\"tvmaze\"");
|
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]
|
#[test]
|
||||||
@@ -169,4 +246,16 @@ mod tests {
|
|||||||
assert_eq!(parsed.tv_metadata_source, TvMetadataSource::Tmdb);
|
assert_eq!(parsed.tv_metadata_source, TvMetadataSource::Tmdb);
|
||||||
assert_eq!(parsed.tmdb_token, Some("abc123".to_string()));
|
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;
|
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 {
|
.history-batch-list {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|||||||
+185
-50
@@ -106,9 +106,15 @@ type ConflictPolicy = "skip" | "autoNumber" | "override";
|
|||||||
|
|
||||||
// Mirrors aurora-curator-core's `TvMetadataSource` - which online source Fetch Data
|
// 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
|
// 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
|
// only source before TVmaze existed); "tvmaze" needs no API token at all; "omdb" needs
|
||||||
// use TMDB regardless of this setting - TVmaze has no movie catalog to pick between.
|
// its own separate API key (see MovieMetadataSource below - the two settings are
|
||||||
type TvMetadataSource = "tmdb" | "tvmaze";
|
// 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
|
// 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
|
// 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 }> = {
|
const TV_SOURCE_LABELS: Record<TvMetadataSource, { button: string; description: string }> = {
|
||||||
tmdb: {
|
tmdb: {
|
||||||
button: "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: {
|
tvmaze: {
|
||||||
button: "TVmaze",
|
button: "TVmaze",
|
||||||
description: "Uses TVmaze for TV episode titles and air dates — free, no API token needed at all.",
|
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 {
|
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
|
// nothing saved, `true` = a token is on file (the value itself never comes back to
|
||||||
// the frontend after saving - see Settings modal below).
|
// the frontend after saving - see Settings modal below).
|
||||||
const [hasTmdbToken, setHasTmdbToken] = useState<boolean | null>(null);
|
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
|
// 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
|
// `src-tauri/src/settings.rs`), defaults to "tmdb" until loaded.
|
||||||
// TMDB regardless of this, so it only ever gates the TV half of Fetch Data.
|
|
||||||
const [tvSource, setTvSource] = useState<TvMetadataSource>("tmdb");
|
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 [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [tmdbTokenInput, setTmdbTokenInput] = useState("");
|
const [tmdbTokenInput, setTmdbTokenInput] = useState("");
|
||||||
|
const [omdbKeyInput, setOmdbKeyInput] = useState("");
|
||||||
const [savingSettings, setSavingSettings] = useState(false);
|
const [savingSettings, setSavingSettings] = useState(false);
|
||||||
const [fetching, setFetching] = useState(false);
|
const [fetching, setFetching] = useState(false);
|
||||||
// Rename history / undo. `null` batches = not loaded yet (also used to show a
|
// Rename history / undo. `null` batches = not loaded yet (also used to show a
|
||||||
@@ -462,12 +492,24 @@ export default function RenamePanel() {
|
|||||||
.catch(() => setHasTmdbToken(false));
|
.catch(() => setHasTmdbToken(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invoke<string | null>("get_omdb_api_key")
|
||||||
|
.then((key) => setHasOmdbKey(!!key))
|
||||||
|
.catch(() => setHasOmdbKey(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
invoke<TvMetadataSource>("get_tv_metadata_source")
|
invoke<TvMetadataSource>("get_tv_metadata_source")
|
||||||
.then(setTvSource)
|
.then(setTvSource)
|
||||||
.catch(() => setTvSource("tmdb"));
|
.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
|
// 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.
|
// latest expression/items instead of the stale values from its first render.
|
||||||
const expressionRef = useRef(expression);
|
const expressionRef = useRef(expression);
|
||||||
@@ -907,27 +949,40 @@ export default function RenamePanel() {
|
|||||||
|
|
||||||
const openSettings = () => {
|
const openSettings = () => {
|
||||||
setTmdbTokenInput("");
|
setTmdbTokenInput("");
|
||||||
|
setOmdbKeyInput("");
|
||||||
setSettingsOpen(true);
|
setSettingsOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveTmdbToken = async () => {
|
// Saves whichever credential field(s) the user actually typed into, then closes the
|
||||||
const trimmed = tmdbTokenInput.trim();
|
// modal - a single Save button covers both the TMDB token and the OMDb key so
|
||||||
if (!trimmed) {
|
// 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.
|
// Nothing typed - just close, leaving whatever was already saved untouched.
|
||||||
setSettingsOpen(false);
|
setSettingsOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSavingSettings(true);
|
setSavingSettings(true);
|
||||||
try {
|
try {
|
||||||
await invoke("save_tmdb_token", { token: trimmed });
|
if (trimmedTmdb) {
|
||||||
setHasTmdbToken(true);
|
await invoke("save_tmdb_token", { token: trimmedTmdb });
|
||||||
|
setHasTmdbToken(true);
|
||||||
|
}
|
||||||
|
if (trimmedOmdb) {
|
||||||
|
await invoke("save_omdb_api_key", { key: trimmedOmdb });
|
||||||
|
setHasOmdbKey(true);
|
||||||
|
}
|
||||||
setTmdbTokenInput("");
|
setTmdbTokenInput("");
|
||||||
|
setOmdbKeyInput("");
|
||||||
setSettingsOpen(false);
|
setSettingsOpen(false);
|
||||||
setStatus("TMDB token saved.");
|
setStatus("Fetch Data settings saved.");
|
||||||
window.clearTimeout((saveTmdbToken as any)._t);
|
window.clearTimeout((saveCredentials as any)._t);
|
||||||
(saveTmdbToken as any)._t = window.setTimeout(() => setStatus(null), 3000);
|
(saveCredentials as any)._t = window.setTimeout(() => setStatus(null), 3000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(`Couldn't save the token: ${err}`);
|
setStatus(`Couldn't save: ${err}`);
|
||||||
} finally {
|
} finally {
|
||||||
setSavingSettings(false);
|
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
|
// "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/
|
// 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
|
// 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
|
// 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
|
// resolve against whichever source `tvSource` currently picks (TMDB, TVmaze, or
|
||||||
// items always resolve against TMDB. Both run via separate Tauri commands,
|
// 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.
|
// concurrently, and merge back into a single set of counts/status message.
|
||||||
const runFetch = async () => {
|
const runFetch = async () => {
|
||||||
const tvTargets = items.filter(
|
const tvTargets = items.filter(
|
||||||
@@ -985,12 +1070,19 @@ export default function RenamePanel() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TVmaze needs no token at all, so a TMDB token is only actually required when
|
// TVmaze needs no credential at all, so TMDB/OMDb are only actually required when
|
||||||
// there are movie targets (always TMDB) or TV targets while TMDB is the chosen
|
// this batch's TV or movie targets are actually pointed at that source - a TV-only
|
||||||
// source - a TV-only batch on TVmaze can run with no TMDB token configured at all.
|
// batch on TVmaze can run with neither credential configured at all.
|
||||||
const needsTmdbToken = movieTargets.length > 0 || (tvTargets.length > 0 && tvSource === "tmdb");
|
const needsTmdbToken =
|
||||||
if (needsTmdbToken && hasTmdbToken === false) {
|
(movieTargets.length > 0 && movieSource === "tmdb") || (tvTargets.length > 0 && tvSource === "tmdb");
|
||||||
setStatus("Add a free TMDB API token in Settings first, then Fetch Data will work.");
|
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);
|
setSettingsOpen(true);
|
||||||
window.clearTimeout((runFetch as any)._t);
|
window.clearTimeout((runFetch as any)._t);
|
||||||
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
(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
|
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
|
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
|
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{" "}
|
air/release date. TV episodes can come from TMDB, TVmaze, or OMDb, and movies from TMDB or OMDb — pick
|
||||||
<strong>Fetch Data Settings</strong> (TVmaze is free and needs no token at all); movies always use TMDB
|
each independently in <strong>Fetch Data Settings</strong> (TVmaze is free and needs no credential at
|
||||||
and need a free API token set there first. A batch can
|
all; TMDB and OMDb each need their own free API credential 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
|
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>,
|
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
|
<code>{"{vf}"}</code>, <code>{"{vc}"}</code>, <code>{"{ac}"}</code>, <code>{"{group}"}</code>, etc.) still
|
||||||
work too, side by side with the names above.
|
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>
|
<h2 className="modal-title">Fetch Data Settings</h2>
|
||||||
<span className="modal-field-label">TV episode source</span>
|
<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">
|
<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
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1498,17 +1590,38 @@ export default function RenamePanel() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="modal-subtitle">{TV_SOURCE_LABELS[tvSource].description}</p>
|
<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">
|
<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">
|
<a href="https://www.themoviedb.org/" target="_blank" rel="noreferrer">
|
||||||
TheMovieDB
|
TMDB
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
regardless of the TV source above — TVmaze has no movie catalog. Get a free{" "}
|
account under Settings → API, and/or a free API key from{" "}
|
||||||
<strong>API Read Access Token</strong> from your TMDB account under Settings → API, then paste it below.
|
<a href="https://www.omdbapi.com/apikey.aspx" target="_blank" rel="noreferrer">
|
||||||
The token is stored locally on this machine and never leaves it except to call TMDB directly.
|
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>
|
</p>
|
||||||
<label htmlFor="tmdb-token" className="modal-field-label">
|
<label htmlFor="tmdb-token" className="modal-field-label">
|
||||||
API Read Access Token
|
TMDB API Read Access Token
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="tmdb-token"
|
id="tmdb-token"
|
||||||
@@ -1520,22 +1633,44 @@ export default function RenamePanel() {
|
|||||||
onChange={(e) => setTmdbTokenInput(e.target.value)}
|
onChange={(e) => setTmdbTokenInput(e.target.value)}
|
||||||
placeholder={hasTmdbToken ? "•••••••••• (already saved — enter a new token to replace it)" : "Paste your TMDB token here"}
|
placeholder={hasTmdbToken ? "•••••••••• (already saved — enter a new token to replace it)" : "Paste your TMDB token here"}
|
||||||
/>
|
/>
|
||||||
<div className="modal-actions modal-actions-split">
|
{hasTmdbToken && (
|
||||||
{hasTmdbToken ? (
|
<button
|
||||||
<button className="action-button" onClick={clearTmdbToken} disabled={savingSettings}>
|
className="action-button modal-inline-remove"
|
||||||
Remove Token
|
onClick={clearTmdbToken}
|
||||||
</button>
|
disabled={savingSettings}
|
||||||
) : (
|
>
|
||||||
<span />
|
Remove TMDB Token
|
||||||
)}
|
</button>
|
||||||
<div className="modal-actions">
|
)}
|
||||||
<button className="action-button" onClick={() => setSettingsOpen(false)} disabled={savingSettings}>
|
<label htmlFor="omdb-key" className="modal-field-label">
|
||||||
Cancel
|
OMDb API Key
|
||||||
</button>
|
</label>
|
||||||
<button className="action-button primary" onClick={saveTmdbToken} disabled={savingSettings}>
|
<input
|
||||||
{savingSettings ? "Saving…" : "Save"}
|
id="omdb-key"
|
||||||
</button>
|
className="modal-text-input"
|
||||||
</div>
|
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>
|
||||||
|
)}
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="action-button" onClick={() => setSettingsOpen(false)} disabled={savingSettings}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="action-button primary" onClick={saveCredentials} disabled={savingSettings}>
|
||||||
|
{savingSettings ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user