Add TVmaze as a second, user-selectable TV metadata source for Fetch Data
TV episodes can now resolve against either TMDB (default) or TVmaze - picked via a segmented source picker in the renamed "Fetch Data Settings" modal. TVmaze is free and needs no API token at all; movies still always resolve against TMDB regardless of the choice, since TVmaze has no movie catalog. - New TvmazeClient (crates/aurora-curator-core/src/web/tvmaze.rs): search + season/episode lookup against api.tvmaze.com, filtering out unnumbered specials rather than panicking on them (TVmaze returns every season's episodes in one flat list, unlike TMDB's per-season endpoint). - New TvMetadataSource setting (settings.rs), persisted alongside the TMDB token, defaulting to Tmdb for schema-evolution safety. - metadata.rs now branches TV fetches through an AnyTvClient wrapper so the existing per-(series, season) caching loop doesn't care which source produced a result; the movie-fetch path is untouched. - Frontend: TMDB/TVmaze picker in Fetch Data Settings, persisted on change; Fetch Data's token gating now only requires a TMDB token when it's actually needed (movies always, TV only when TMDB is the chosen source) instead of unconditionally blocking on it. cargo test --workspace: 88/88 passing (81 + 7 new TVmaze + 3 new settings). npm run build passing. Playwright verification: 18/18 checks (picker renders and persists across reload, TV Fetch Data succeeds on TVmaze with zero TMDB token configured, movies still correctly demand a token even with TVmaze selected, switching back to TMDB still works end-to-end).
This commit is contained in:
@@ -6,10 +6,13 @@ fork), built from scratch as its own project: new stack, new architecture, new b
|
||||
**Status: early scaffold, but the core loop works end to end.** The naming-expression
|
||||
engine is ported and tested, the faithfully-recreated two-pane UI is wired to real
|
||||
native file/folder loading and drag-and-drop, local filename-based season/episode *and
|
||||
movie title/year* detection ("Match") is working, real online metadata lookups against
|
||||
TMDB ("Fetch Data") are working for both TV episodes and movies — including fuzzy
|
||||
title disambiguation, so a search that returns several similarly-named results doesn't
|
||||
just take TMDB's top-ranked hit — and the actual on-disk operation is wired up (with a
|
||||
movie title/year* detection ("Match") is working, real online metadata lookups
|
||||
("Fetch Data") are working for both TV episodes and movies — including fuzzy title
|
||||
disambiguation, so a search that returns several similarly-named results doesn't just
|
||||
take the source's top-ranked hit. TV episodes can resolve against either TMDB or
|
||||
TVmaze — user-selectable in Fetch Data Settings, TMDB stays the default and TVmaze
|
||||
needs no API token at all — while movies always resolve against TMDB, since TVmaze has
|
||||
no movie catalog. The actual on-disk operation is wired up (with a
|
||||
confirmation step showing the full old-path/new-path plan before anything touches disk,
|
||||
a per-row ✕ to drop an individual file from that plan at the last step, a Move/Copy/
|
||||
Symlink selector for what to actually do at each target, and — independently — a Skip/
|
||||
@@ -129,38 +132,43 @@ crates/aurora-curator-core/ Pure Rust core: naming-expression engine (ported),
|
||||
the same module (`guess_release_info` — resolution, source, video/
|
||||
audio codec, release group), text-similarity scoring for
|
||||
disambiguating search results (`similarity/`, partial
|
||||
`EpisodeMetrics` port), a TMDB metadata client (`web/tmdb.rs` — TV
|
||||
search + episode lookup
|
||||
and movie search + lookup, both disambiguated via `similarity/`,
|
||||
tested against a mocked HTTP server via `mockito`), the on-disk
|
||||
`EpisodeMetrics` port), two web metadata clients — `web/tmdb.rs`
|
||||
(TV search + episode lookup and movie search + lookup) and
|
||||
`web/tvmaze.rs` (TV search + episode lookup only — TVmaze has no
|
||||
movie catalog, and no API key/auth at all), both disambiguated via
|
||||
`similarity/` and tested against a mocked HTTP server via
|
||||
`mockito`, the on-disk
|
||||
rename engine (`rename/` — Move/Copy/Symlink actions,
|
||||
Skip/Auto-number/Override conflict policies, can also organize
|
||||
into (auto-created) subfolders when
|
||||
the naming scheme includes `/`) — and a rename history/undo log
|
||||
(`history/`, a partial `HistoryDialog` port, undo-aware of which
|
||||
action produced each batch). Not yet ported: full media metadata
|
||||
bindings, other web datasource clients (TVDB/AniDB/etc).
|
||||
bindings, other web datasource clients (TheTVDB/AniDB/OMDb/etc).
|
||||
No Tauri dependency, so it builds and unit-tests standalone.
|
||||
src-tauri/ Tauri app shell: exposes aurora-curator-core as commands to the frontend
|
||||
(naming-scheme rendering, filename matching for both TV and
|
||||
movies, TMDB metadata fetch — `metadata.rs` for TV episodes,
|
||||
plus a movie-fetch command alongside it — executing a rename
|
||||
movies, metadata fetch — `metadata.rs` for TV episodes (against
|
||||
whichever of TMDB/TVmaze is the stored source) plus a
|
||||
movie-fetch command alongside it (always TMDB) — executing a rename
|
||||
plan, native file/folder dialogs, drag-and-drop expansion), plus
|
||||
`settings.rs` (TMDB token), `history.rs` (rename history/undo
|
||||
`settings.rs` (TMDB token, and which TV metadata source Fetch Data
|
||||
uses), `history.rs` (rename history/undo
|
||||
log), and `presets.rs` (up to 9 saved naming schemes) — all three
|
||||
stored as JSON in the OS app-config directory — app
|
||||
metadata/icons/window config (tauri.conf.json).
|
||||
src/ React + TypeScript UI (Vite). Faithful recreation of the original
|
||||
two-pane rename screen (Aurora theme) wired to real file loading,
|
||||
drag-and-drop, live naming-scheme preview, local TV/movie
|
||||
matching, TMDB fetch for both (behind a Settings modal for the
|
||||
API token), a confirm-before-touching-disk modal for the actual
|
||||
matching, metadata fetch for both (behind a Fetch Data Settings
|
||||
modal holding the TMDB/TVmaze source picker and the API token), a
|
||||
confirm-before-touching-disk modal for the actual
|
||||
rename, a History panel that can undo a past rename, Align Up/Down
|
||||
list reordering, and a Presets panel (plus `1`-`9` hotkeys) for
|
||||
saved naming schemes.
|
||||
scripts/gen_icons.py Regenerates src-tauri/icons/ (placeholder branding — swap the
|
||||
colors/glyph in the script, or replace the icons directly, to
|
||||
change the look).
|
||||
scripts/gen_icons.py Regenerates src-tauri/icons/ from the real logo mark
|
||||
(`scripts/logo/icon-source.png`, cropped from the supplied
|
||||
lockup) — replace that source image to change the look.
|
||||
```
|
||||
|
||||
## Building
|
||||
@@ -183,24 +191,35 @@ npm run tauri build
|
||||
```
|
||||
|
||||
Verified in the environment this was built in: `cargo test -p aurora-curator-core` (all
|
||||
passing, including the TMDB client tests — those run against a local mock HTTP server
|
||||
via `mockito`, no live network or API key needed to build/test), `cargo check -p
|
||||
passing, including the TMDB and TVmaze client tests — those run against a local mock
|
||||
HTTP server via `mockito`, no live network or API key needed to build/test), `cargo check -p
|
||||
aurora-curator` (compiles clean against real Tauri + GTK/WebKit system libraries), and `npm run
|
||||
build` (TypeScript type-checks, Vite bundles). The actual GUI window has **not** been
|
||||
visually verified anywhere yet — that sandbox has no display. Run `npm run tauri dev`
|
||||
on your machine to see and click through it for the first time.
|
||||
|
||||
### Setting up TMDB (for "Fetch Data")
|
||||
### Setting up metadata sources (for "Fetch Data")
|
||||
|
||||
Everything else in the app works fully offline; "Fetch Data" is the one feature that
|
||||
needs a (free) [TMDB](https://www.themoviedb.org/) account:
|
||||
talks to the network, and it's the only one that needs any setup at all — and even that
|
||||
setup is optional for TV.
|
||||
|
||||
1. Create a TMDB account, then go to Settings → API in your account and generate an
|
||||
**API Read Access Token** (not the older numeric "API Key" — Aurora Curator uses the Bearer
|
||||
token method TMDB documents as the default).
|
||||
2. In the app, click the key icon ("TMDB Settings") next to Fetch Data and paste the
|
||||
token in. It's saved locally in the OS app-config directory and only ever sent to
|
||||
`api.themoviedb.org`.
|
||||
TV episodes can resolve against either of two sources, picked in the app (click the key
|
||||
icon, "Fetch Data Settings", next to Fetch Data):
|
||||
|
||||
- **[TVmaze](https://www.tvmaze.com/)** — free, no account or API token needed at all.
|
||||
Just pick it and go.
|
||||
- **[TMDB](https://www.themoviedb.org/)** — the default, and the only option for movies
|
||||
(TVmaze has no movie catalog). Needs a free account:
|
||||
1. Create a TMDB account, then go to Settings → API in your account and generate an
|
||||
**API Read Access Token** (not the older numeric "API Key" — Aurora Curator uses the
|
||||
Bearer token method TMDB documents as the default).
|
||||
2. In the app, open "Fetch Data Settings" and paste the token in. It's saved locally
|
||||
in the OS app-config directory and only ever sent to `api.themoviedb.org`.
|
||||
|
||||
A batch of TV-only files can run entirely on TVmaze with no token configured at all; a
|
||||
batch containing any movies still needs the TMDB token regardless of which TV source is
|
||||
selected.
|
||||
|
||||
## Porting roadmap (not yet done)
|
||||
|
||||
@@ -240,13 +259,20 @@ ported from, for anyone picking this up:
|
||||
4. **Media detection** — `net.filebot.media.MediaDetection` (1356 lines): ties file-name
|
||||
parsing + matching together to auto-identify what a file is.
|
||||
5. **Web datasource clients** — `net.filebot.web.*` (~40 files): TheMovieDB, TheTVDB,
|
||||
AniDB, OMDb, TVmaze, OpenSubtitles, etc. TMDB is done for both TV and movies
|
||||
AniDB, OMDb, TVmaze, OpenSubtitles, etc. Two are done: TMDB, for both TV and movies
|
||||
(`crates/aurora-curator-core/src/web/tmdb.rs`, `reqwest` + `serde_json`, Bearer-token auth) —
|
||||
search-by-name plus per-season episode lookup for TV, search-by-title (optionally
|
||||
narrowed by release year via TMDB's own `primary_release_year` filter) for movies —
|
||||
both wired to "Fetch Data", disambiguating multi-result searches via the
|
||||
similarity-scoring pass described in item 3 above rather than blindly trusting
|
||||
TMDB's top-ranked hit. All other sources (TheTVDB, AniDB, OMDb, TVmaze,
|
||||
and TVmaze, for TV only (`crates/aurora-curator-core/src/web/tvmaze.rs`, same `reqwest` +
|
||||
`serde_json` shape, no auth at all — TVmaze has no movie catalog to search, and its
|
||||
per-show episode endpoint returns every season in one flat list rather than TMDB's
|
||||
per-season lookup, so the client fetches once and filters locally, skipping any
|
||||
unnumbered/special episodes rather than panicking on them). Both are wired to "Fetch
|
||||
Data" — a user-selectable "TV episode source" picker in Fetch Data Settings chooses
|
||||
which of the two a batch's TV items resolve against (TMDB stays the default; movies
|
||||
always use TMDB regardless of that picker) — disambiguating multi-result searches via
|
||||
the similarity-scoring pass described in item 3 above rather than blindly trusting
|
||||
either source's top-ranked hit. All other sources (TheTVDB, AniDB, OMDb,
|
||||
OpenSubtitles) are not yet started.
|
||||
6. **Rename engine + CLI** — `net.filebot.cli.CmdlineOperations` (1235 lines) and
|
||||
`ArgumentBean` (415 lines, the full flag surface: `-rename`, `--db`, `--format`,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
//! Online metadata clients ("Fetch Data"). Currently just TMDB; the original FileBot
|
||||
//! supports ~40 sources (`net.filebot.web.*`) - TMDB was picked first since it's the
|
||||
//! most widely useful for both TV and movies and has a simple, well-documented API.
|
||||
//! Online metadata clients ("Fetch Data"). TMDB (TV + movies) and TVmaze (TV only, no
|
||||
//! API key needed) so far; the original FileBot supports ~40 sources
|
||||
//! (`net.filebot.web.*`) - TMDB was picked first since it's the most widely useful for
|
||||
//! both TV and movies, TVmaze second since it's a zero-setup second TV source (see
|
||||
//! `tvmaze`'s module doc for why movies aren't in scope for it).
|
||||
|
||||
pub mod tmdb;
|
||||
pub mod tvmaze;
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
//! A small client for [TVmaze](https://www.tvmaze.com/)'s free REST API: search for a
|
||||
//! TV show by name, then look up a specific episode's real title/air date.
|
||||
//!
|
||||
//! TV-only (TVmaze doesn't have a movie catalog at all, unlike TMDB) and, unlike TMDB,
|
||||
//! needs **no API key** - every endpoint used here is fully public. That makes it a
|
||||
//! zero-setup second TV metadata source: a user who doesn't want to register for a TMDB
|
||||
//! token, or whose show just isn't on TMDB, can pick this instead in Fetch Data
|
||||
//! Settings. See `crate::web::tmdb` for the TMDB client this mirrors the shape of.
|
||||
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::similarity::best_title_match;
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://api.tvmaze.com";
|
||||
|
||||
/// Same threshold TMDB's client uses (see `tmdb::NAME_MATCH_THRESHOLD`) - kept
|
||||
/// identical so switching sources doesn't change how forgiving/strict name matching
|
||||
/// feels to the user.
|
||||
const NAME_MATCH_THRESHOLD: f64 = 0.35;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TvmazeError {
|
||||
#[error("network error talking to TVmaze: {0}")]
|
||||
Network(String),
|
||||
#[error("TVmaze API error (status {status}): {message}")]
|
||||
Api { status: u16, message: String },
|
||||
#[error("no TVmaze match found for \"{0}\"")]
|
||||
NotFound(String),
|
||||
}
|
||||
|
||||
/// One TVmaze search hit's show data. Only the fields Aurora Curator actually uses are
|
||||
/// kept - TVmaze's `/search/shows` wraps each show in a `{score, show: {...}}` envelope
|
||||
/// (the score is TVmaze's own relevance ranking, not used here - `best_title_match`
|
||||
/// re-ranks by name similarity instead, same as the TMDB client does).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TvResult {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct SearchHit {
|
||||
show: TvResult,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct EpisodeResult {
|
||||
season: i32,
|
||||
number: Option<i32>,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
airdate: Option<String>,
|
||||
}
|
||||
|
||||
/// Real metadata for one episode, resolved from TVmaze - same shape as
|
||||
/// `tmdb::EpisodeInfo` so the Tauri glue can treat either 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`'s field names so both clients' results map onto the same local
|
||||
/// shape in the Tauri glue.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SeasonEpisode {
|
||||
pub episode_number: i32,
|
||||
pub title: String,
|
||||
pub air_date: Option<String>,
|
||||
}
|
||||
|
||||
pub struct TvmazeClient {
|
||||
http: reqwest::Client,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl Default for TvmazeClient {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TvmazeClient {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
http: reqwest::Client::new(),
|
||||
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 TVmaze API.
|
||||
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||
self.base_url = base_url.into();
|
||||
self
|
||||
}
|
||||
|
||||
async fn get_json<T: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
path: &str,
|
||||
query: &[(&str, &str)],
|
||||
) -> Result<T, TvmazeError> {
|
||||
let url = format!("{}{path}", self.base_url);
|
||||
let response = self
|
||||
.http
|
||||
.get(&url)
|
||||
.query(query)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| TvmazeError::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(TvmazeError::Api { status, message });
|
||||
}
|
||||
|
||||
response
|
||||
.json::<T>()
|
||||
.await
|
||||
.map_err(|e| TvmazeError::Network(e.to_string()))
|
||||
}
|
||||
|
||||
/// Searches for a TV show by name. Results come back TVmaze-ranked by its own
|
||||
/// relevance score; callers wanting "the best guess" should use
|
||||
/// `search_tv_best_match` instead, same convention as the TMDB client.
|
||||
pub async fn search_tv(&self, query: &str) -> Result<Vec<TvResult>, TvmazeError> {
|
||||
let hits: Vec<SearchHit> = self.get_json("/search/shows", &[("q", query)]).await?;
|
||||
Ok(hits.into_iter().map(|h| h.show).collect())
|
||||
}
|
||||
|
||||
/// Fetches EVERY episode of a show (all seasons) in a single request - TVmaze's
|
||||
/// `/shows/{id}/episodes` endpoint doesn't take a season filter, unlike TMDB's
|
||||
/// per-season endpoint, so this fetches once and filters locally. Prefer calling
|
||||
/// this once and reusing the result over calling it per-season/per-episode.
|
||||
async fn all_episodes(&self, series_id: u64) -> Result<Vec<EpisodeResult>, TvmazeError> {
|
||||
let path = format!("/shows/{series_id}/episodes");
|
||||
self.get_json(&path, &[]).await
|
||||
}
|
||||
|
||||
/// Fetches one season's episodes for a known TVmaze show id - mirrors
|
||||
/// `TmdbClient::season_episodes`'s signature/return shape even though the
|
||||
/// underlying TVmaze call fetches (and then filters) every season at once.
|
||||
pub async fn season_episodes(&self, series_id: u64, season: i32) -> Result<Vec<SeasonEpisode>, TvmazeError> {
|
||||
let episodes = self.all_episodes(series_id).await?;
|
||||
Ok(episodes
|
||||
.into_iter()
|
||||
.filter(|e| e.season == season && e.number.is_some())
|
||||
.map(|e| SeasonEpisode {
|
||||
episode_number: e.number.expect("filtered to Some above"),
|
||||
title: e.name,
|
||||
air_date: e.airdate,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Searches for a TV show by name and returns the single best-matching result (see
|
||||
/// `similarity::best_title_match`), or `None` if nothing in the results is a
|
||||
/// plausible match at all. Mirrors `TmdbClient::search_tv_best_match`.
|
||||
pub async fn search_tv_best_match(&self, query: &str) -> Result<Option<TvResult>, TvmazeError> {
|
||||
let results = self.search_tv(query).await?;
|
||||
Ok(best_title_match(query, &results, |r| r.name.as_str(), NAME_MATCH_THRESHOLD).cloned())
|
||||
}
|
||||
|
||||
/// Convenience: search for the best-matching show by name, then fetch a specific
|
||||
/// episode. Same disambiguation approach as `TmdbClient::lookup_episode` - picks
|
||||
/// whichever result's name is textually closest to `series_query` rather than
|
||||
/// trusting TVmaze's own top-ranked result, 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, TvmazeError> {
|
||||
let best = self
|
||||
.search_tv_best_match(series_query)
|
||||
.await?
|
||||
.ok_or_else(|| TvmazeError::NotFound(series_query.to_string()))?;
|
||||
|
||||
let episodes = self.season_episodes(best.id, season).await?;
|
||||
let found = episodes
|
||||
.into_iter()
|
||||
.find(|e| e.episode_number == episode)
|
||||
.ok_or_else(|| TvmazeError::NotFound(format!("S{season:02}E{episode:02}")))?;
|
||||
|
||||
Ok(EpisodeInfo {
|
||||
series_name: best.name,
|
||||
episode_title: found.title,
|
||||
air_date: found.air_date,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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", "/search/shows")
|
||||
.match_query(mockito::Matcher::UrlEncoded("q".into(), "Girls".into()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"[{"score":10.5,"show":{"id":139,"name":"Girls"}}]"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
// TVmaze returns every season's episodes in one call - the mock reflects that,
|
||||
// with episodes from two different seasons mixed together to prove filtering
|
||||
// by season actually happens rather than just taking whatever's returned.
|
||||
let _episodes_mock = server
|
||||
.mock("GET", "/shows/139/episodes")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"[
|
||||
{"season":1,"number":1,"name":"Pilot","airdate":"2012-04-15"},
|
||||
{"season":2,"number":1,"name":"It's About Time","airdate":"2013-01-13"},
|
||||
{"season":2,"number":2,"name":"I Get Ideas","airdate":"2013-01-20"}
|
||||
]"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TvmazeClient::new().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", "/search/shows")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body("[]")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TvmazeClient::new().with_base_url(server.url());
|
||||
let result = client.lookup_episode("Some Nonexistent Show", 1, 1).await;
|
||||
|
||||
assert!(matches!(result, Err(TvmazeError::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", "/search/shows")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"[{"score":1.0,"show":{"id":42,"name":"Some Show"}}]"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let _episodes_mock = server
|
||||
.mock("GET", "/shows/42/episodes")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"[{"season":1,"number":1,"name":"Pilot","airdate":"2020-01-01"}]"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TvmazeClient::new().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(TvmazeError::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", "/search/shows")
|
||||
.match_query(mockito::Matcher::UrlEncoded("q".into(), "Chuck".into()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"[{"score":20.0,"show":{"id":1,"name":"Chucky"}},{"score":5.0,"show":{"id":2,"name":"Chuck"}}]"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let _episodes_mock = server
|
||||
.mock("GET", "/shows/2/episodes")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"[{"season":1,"number":1,"name":"Chuck Versus the Intersect","airdate":"2007-09-24"}]"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TvmazeClient::new().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", "/search/shows")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"[{"score":1.0,"show":{"id":9,"name":"Totally Unrelated Program"}}]"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TvmazeClient::new().with_base_url(server.url());
|
||||
let result = client.lookup_episode("Zzyzx Nonexistent Show", 1, 1).await;
|
||||
|
||||
assert!(matches!(result, Err(TvmazeError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn episodes_with_no_episode_number_are_skipped_rather_than_panicking() {
|
||||
// TVmaze occasionally has "special"/non-numbered entries (e.g. season 0
|
||||
// specials, or episodes with number:null) mixed in with regular episodes -
|
||||
// make sure filtering by season doesn't choke on one, since a naive `.unwrap()`
|
||||
// on `number` would panic on real data.
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _search_mock = server
|
||||
.mock("GET", "/search/shows")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"[{"score":1.0,"show":{"id":7,"name":"Some Show"}}]"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let _episodes_mock = server
|
||||
.mock("GET", "/shows/7/episodes")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"[
|
||||
{"season":1,"number":null,"name":"Uncategorized Special","airdate":null},
|
||||
{"season":1,"number":1,"name":"Pilot","airdate":"2021-01-01"}
|
||||
]"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TvmazeClient::new().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 surfaces_a_server_error_as_an_api_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _mock = server
|
||||
.mock("GET", "/search/shows")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(503)
|
||||
.with_header("content-type", "text/plain")
|
||||
.with_body("service unavailable")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TvmazeClient::new().with_base_url(server.url());
|
||||
let result = client.search_tv("anything").await;
|
||||
|
||||
match result {
|
||||
Err(TvmazeError::Api { status, .. }) => assert_eq!(status, 503),
|
||||
other => panic!("expected Api error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,8 @@ pub fn run() {
|
||||
metadata::fetch_movie_metadata,
|
||||
settings::get_tmdb_token,
|
||||
settings::save_tmdb_token,
|
||||
settings::get_tv_metadata_source,
|
||||
settings::save_tv_metadata_source,
|
||||
history::list_rename_history,
|
||||
history::undo_rename_batch,
|
||||
presets::list_presets,
|
||||
|
||||
+111
-37
@@ -1,15 +1,21 @@
|
||||
//! The Tauri-facing side of "Fetch Data": takes the local season/episode guesses the
|
||||
//! UI already has (from `guess_episode`/Match) and resolves them against real TMDB
|
||||
//! UI already has (from `guess_episode`/Match) and resolves them against real online
|
||||
//! metadata. Deliberately layered on top of local matching rather than replacing it -
|
||||
//! Fetch Data needs a series name and season/episode to search with, which is exactly
|
||||
//! what Match already produces for free, offline.
|
||||
//!
|
||||
//! TV lookups can go against either TMDB or TVmaze - whichever `settings::tv_metadata_
|
||||
//! source` currently has stored (see `AnyTvClient` below), defaulting to TMDB. Movie
|
||||
//! lookups always go against TMDB regardless of that setting; TVmaze has no movie
|
||||
//! catalog to pick between.
|
||||
|
||||
use aurora_curator_core::web::tmdb::{SeasonEpisode, TmdbClient};
|
||||
use aurora_curator_core::web::tmdb::TmdbClient;
|
||||
use aurora_curator_core::web::tvmaze::TvmazeClient;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::settings;
|
||||
use crate::settings::{self, TvMetadataSource};
|
||||
|
||||
/// One file's local guess, sent up from the UI to be resolved against TMDB.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -44,34 +50,115 @@ pub struct FetchResult {
|
||||
pub outcome: FetchOutcome,
|
||||
}
|
||||
|
||||
/// Resolves a batch of local guesses against TMDB. Runs off the main thread since it's
|
||||
/// doing real network I/O; not `blocking_*`-style Tauri API, so no deadlock risk like
|
||||
/// the dialog commands had - `spawn` here is just to keep it explicitly off the
|
||||
/// command-invocation path some Tauri internals run on.
|
||||
/// One episode of a resolved season - the shape both TMDB's and TVmaze's own
|
||||
/// per-source episode types get mapped into by `AnyTvClient::fetch_season`, so the
|
||||
/// caching/lookup loop in `run_fetch` never needs to know which source produced a
|
||||
/// result.
|
||||
struct LocalEpisode {
|
||||
episode_number: i32,
|
||||
title: String,
|
||||
air_date: Option<String>,
|
||||
}
|
||||
|
||||
type SeasonLookup = Result<(String, Vec<LocalEpisode>), String>;
|
||||
|
||||
/// Either TV metadata client, picked once per `fetch_metadata` call based on the
|
||||
/// stored `TvMetadataSource` setting, then reused across every item in the batch (so a
|
||||
/// setting change mid-batch can't produce mixed-source results within one Fetch Data
|
||||
/// click).
|
||||
enum AnyTvClient {
|
||||
Tmdb(TmdbClient),
|
||||
Tvmaze(TvmazeClient),
|
||||
}
|
||||
|
||||
impl AnyTvClient {
|
||||
/// The human-readable source name, used only in error messages so a "no match"
|
||||
/// failure tells the user which source actually came up empty.
|
||||
fn source_name(&self) -> &'static str {
|
||||
match self {
|
||||
AnyTvClient::Tmdb(_) => "TMDB",
|
||||
AnyTvClient::Tvmaze(_) => "TVmaze",
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_season(&self, series_query: &str, season: i32) -> SeasonLookup {
|
||||
match self {
|
||||
AnyTvClient::Tmdb(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.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(),
|
||||
))
|
||||
}
|
||||
AnyTvClient::Tvmaze(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.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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a batch of local guesses against real TV metadata. Runs off the main
|
||||
/// thread since it's doing real network I/O; not `blocking_*`-style Tauri API, so no
|
||||
/// deadlock risk like the dialog commands had - `spawn` here is just to keep it
|
||||
/// explicitly off the command-invocation path some Tauri internals run on.
|
||||
///
|
||||
/// Only the TMDB path needs a token; TVmaze needs none at all, so a batch can run
|
||||
/// against TVmaze even with no TMDB token configured (movies, elsewhere in this file,
|
||||
/// always need the TMDB token regardless of this setting).
|
||||
#[tauri::command]
|
||||
pub async fn fetch_metadata(app: AppHandle, items: Vec<FetchQuery>) -> Vec<FetchResult> {
|
||||
let Some(token) = settings::tmdb_token(&app) else {
|
||||
return items
|
||||
.into_iter()
|
||||
.map(|item| FetchResult {
|
||||
id: item.id,
|
||||
outcome: FetchOutcome::Failed {
|
||||
error: "No TMDB API token configured — open Settings to add one.".to_string(),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
let client = match settings::tv_metadata_source(&app) {
|
||||
TvMetadataSource::Tmdb => {
|
||||
let Some(token) = settings::tmdb_token(&app) else {
|
||||
return items
|
||||
.into_iter()
|
||||
.map(|item| FetchResult {
|
||||
id: item.id,
|
||||
outcome: FetchOutcome::Failed {
|
||||
error: "No TMDB API token configured — open Settings to add one.".to_string(),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
};
|
||||
AnyTvClient::Tmdb(TmdbClient::new(token))
|
||||
}
|
||||
TvMetadataSource::Tvmaze => AnyTvClient::Tvmaze(TvmazeClient::new()),
|
||||
};
|
||||
|
||||
tauri::async_runtime::spawn(run_fetch(token, items))
|
||||
tauri::async_runtime::spawn(run_fetch(client, items))
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
type SeasonLookup = Result<(String, Vec<SeasonEpisode>), String>;
|
||||
|
||||
async fn run_fetch(token: String, items: Vec<FetchQuery>) -> Vec<FetchResult> {
|
||||
let client = TmdbClient::new(token);
|
||||
|
||||
async fn run_fetch(client: AnyTvClient, items: Vec<FetchQuery>) -> Vec<FetchResult> {
|
||||
// Cache per (series_query, season): a whole loaded season's worth of files only
|
||||
// needs one search + one season-episode-list fetch, not one per file.
|
||||
let mut season_cache: HashMap<(String, i32), SeasonLookup> = HashMap::new();
|
||||
@@ -81,7 +168,7 @@ async fn run_fetch(token: String, items: Vec<FetchQuery>) -> Vec<FetchResult> {
|
||||
let key = (item.series_query.clone(), item.season);
|
||||
|
||||
if !season_cache.contains_key(&key) {
|
||||
let looked_up = fetch_season(&client, &item.series_query, item.season).await;
|
||||
let looked_up = client.fetch_season(&item.series_query, item.season).await;
|
||||
season_cache.insert(key.clone(), looked_up);
|
||||
}
|
||||
|
||||
@@ -103,19 +190,6 @@ async fn run_fetch(token: String, items: Vec<FetchQuery>) -> Vec<FetchResult> {
|
||||
results
|
||||
}
|
||||
|
||||
async fn fetch_season(client: &TmdbClient, series_query: &str, season: i32) -> SeasonLookup {
|
||||
let best = client
|
||||
.search_tv_best_match(series_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("no TMDB match for \"{series_query}\""))?;
|
||||
let episodes = client
|
||||
.season_episodes(best.id, season)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok((best.name, episodes))
|
||||
}
|
||||
|
||||
/// One file's local movie-title guess, sent up from the UI to be resolved against
|
||||
/// TMDB. Mirrors `FetchQuery`, but for the movie side of "Fetch Data".
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Small persisted app settings - currently just the TMDB API token ("Fetch Data" needs
|
||||
//! it; everything else in the app works offline without any settings at all). Stored as
|
||||
//! plain JSON in Tauri's per-OS app config directory (e.g.
|
||||
//! Small persisted app settings - the TMDB API token, and (since the TVmaze datasource
|
||||
//! was added) which TV metadata source Fetch Data should use ("Fetch Data" is the only
|
||||
//! thing in the app that needs settings at all; everything else works offline). Stored
|
||||
//! as plain JSON in Tauri's per-OS app config directory (e.g.
|
||||
//! `~/Library/Application Support/com.auroracurator.app/settings.json` on macOS,
|
||||
//! `~/.config/aurora-curator/settings.json` on Linux) - not the original FileBot's config
|
||||
//! location, since that's Java Preferences-backed and OS/JVM-specific; this is Aurora Curator's
|
||||
@@ -11,10 +12,28 @@ use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
/// Which online source Fetch Data resolves TV episodes against. Movies always use
|
||||
/// TMDB regardless of this setting - TVmaze doesn't have a movie catalog at all, so
|
||||
/// there's nothing to pick between for the movie half of Fetch Data.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TvMetadataSource {
|
||||
Tmdb,
|
||||
Tvmaze,
|
||||
}
|
||||
|
||||
impl Default for TvMetadataSource {
|
||||
fn default() -> Self {
|
||||
TvMetadataSource::Tmdb
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct StoredSettings {
|
||||
#[serde(default)]
|
||||
tmdb_token: Option<String>,
|
||||
#[serde(default)]
|
||||
tv_metadata_source: TvMetadataSource,
|
||||
}
|
||||
|
||||
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
@@ -59,3 +78,50 @@ pub fn save_tmdb_token(app: AppHandle, token: String) -> Result<(), String> {
|
||||
settings.tmdb_token = if trimmed.is_empty() { None } else { Some(trimmed) };
|
||||
save(&app, &settings)
|
||||
}
|
||||
|
||||
/// Returns the stored TV metadata source (defaults to TMDB if nothing's been chosen).
|
||||
pub fn tv_metadata_source(app: &AppHandle) -> TvMetadataSource {
|
||||
load(app).tv_metadata_source
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_tv_metadata_source(app: AppHandle) -> TvMetadataSource {
|
||||
tv_metadata_source(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_tv_metadata_source(app: AppHandle, source: TvMetadataSource) -> Result<(), String> {
|
||||
let mut settings = load(&app);
|
||||
settings.tv_metadata_source = source;
|
||||
save(&app, &settings)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tv_metadata_source_defaults_to_tmdb() {
|
||||
assert_eq!(TvMetadataSource::default(), TvMetadataSource::Tmdb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tv_metadata_source_serializes_as_lowercase_camel_case() {
|
||||
// The frontend sends/receives this as a plain string ("tmdb"/"tvmaze") - pin
|
||||
// the exact serde output so a future refactor can't silently change the wire
|
||||
// format out from under the TS side without a test catching it.
|
||||
assert_eq!(serde_json::to_string(&TvMetadataSource::Tmdb).unwrap(), "\"tmdb\"");
|
||||
assert_eq!(serde_json::to_string(&TvMetadataSource::Tvmaze).unwrap(), "\"tvmaze\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_settings_without_a_tv_metadata_source_field_still_deserializes() {
|
||||
// Settings written before this field existed (just `{"tmdbToken": "..."}` or
|
||||
// similar) must still load cleanly, defaulting to Tmdb - same
|
||||
// schema-evolution pattern used for `HistoryBatch.action` (see project memory).
|
||||
let old_json = r#"{"tmdb_token":"abc123"}"#;
|
||||
let parsed: StoredSettings = serde_json::from_str(old_json).unwrap();
|
||||
assert_eq!(parsed.tv_metadata_source, TvMetadataSource::Tmdb);
|
||||
assert_eq!(parsed.tmdb_token, Some("abc123".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,10 +104,17 @@ type RenameAction = "move" | "copy" | "symlink";
|
||||
// "override" replaces it outright (irreversible for whatever gets overwritten).
|
||||
type ConflictPolicy = "skip" | "autoNumber" | "override";
|
||||
|
||||
// Mirrors aurora-curator-core's `TvMetadataSource` - which online source Fetch Data
|
||||
// resolves TV episodes against. "tmdb" is the default (matches Aurora Curator's original and
|
||||
// only source before TVmaze existed); "tvmaze" needs no API token at all. Movies always
|
||||
// use TMDB regardless of this setting - TVmaze has no movie catalog to pick between.
|
||||
type TvMetadataSource = "tmdb" | "tvmaze";
|
||||
|
||||
// Sent to `fetch_metadata`: one file's local guess (from Match) to resolve against
|
||||
// real TMDB data. Needs a series name + season/episode to search with - exactly what
|
||||
// Match already produces offline, which is why Fetch Data requires Match to have run
|
||||
// first rather than doing its own from-scratch identification.
|
||||
// real online TV metadata (TMDB or TVmaze, per the stored TvMetadataSource). Needs a
|
||||
// series name + season/episode to search with - exactly what Match already produces
|
||||
// offline, which is why Fetch Data requires Match to have run first rather than doing
|
||||
// its own from-scratch identification.
|
||||
interface FetchQuery {
|
||||
id: string;
|
||||
seriesQuery: string;
|
||||
@@ -312,6 +319,19 @@ const CONFLICT_POLICY_LABELS: Record<ConflictPolicy, { button: string; descripti
|
||||
},
|
||||
};
|
||||
|
||||
// Copy for each TvMetadataSource, used in the Fetch Data Settings modal's segmented
|
||||
// selector and its description note.
|
||||
const TV_SOURCE_LABELS: Record<TvMetadataSource, { button: string; description: string }> = {
|
||||
tmdb: {
|
||||
button: "TMDB",
|
||||
description: "Uses TheMovieDB for TV episode titles and air dates — needs the API token below.",
|
||||
},
|
||||
tvmaze: {
|
||||
button: "TVmaze",
|
||||
description: "Uses TVmaze for TV episode titles and air dates — free, no API token needed at all.",
|
||||
},
|
||||
};
|
||||
|
||||
function realFileToItem(entry: FileEntry): Item {
|
||||
return {
|
||||
id: entry.path,
|
||||
@@ -405,6 +425,10 @@ export default function RenamePanel() {
|
||||
// nothing saved, `true` = a token is on file (the value itself never comes back to
|
||||
// the frontend after saving - see Settings modal below).
|
||||
const [hasTmdbToken, setHasTmdbToken] = useState<boolean | null>(null);
|
||||
// Which online source Fetch Data uses for TV episodes - persisted on disk (see
|
||||
// `src-tauri/src/settings.rs`), defaults to "tmdb" until loaded. Movies always use
|
||||
// TMDB regardless of this, so it only ever gates the TV half of Fetch Data.
|
||||
const [tvSource, setTvSource] = useState<TvMetadataSource>("tmdb");
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [tmdbTokenInput, setTmdbTokenInput] = useState("");
|
||||
const [savingSettings, setSavingSettings] = useState(false);
|
||||
@@ -438,6 +462,12 @@ export default function RenamePanel() {
|
||||
.catch(() => setHasTmdbToken(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
invoke<TvMetadataSource>("get_tv_metadata_source")
|
||||
.then(setTvSource)
|
||||
.catch(() => setTvSource("tmdb"));
|
||||
}, []);
|
||||
|
||||
// Refs so the drag-and-drop listener (registered once on mount) always sees the
|
||||
// latest expression/items instead of the stale values from its first render.
|
||||
const expressionRef = useRef(expression);
|
||||
@@ -919,21 +949,29 @@ export default function RenamePanel() {
|
||||
}
|
||||
};
|
||||
|
||||
// "Fetch Data": resolve each already-Matched file's local guess against real TMDB
|
||||
// Persists the TV-source choice (see Fetch Data Settings modal below). Applied
|
||||
// immediately in the UI regardless of whether the save round-trip succeeds, same as
|
||||
// the rest of this app's settings - a failed disk write shouldn't block using the
|
||||
// app for the rest of the session, just mean the choice doesn't survive a restart.
|
||||
const saveTvSource = async (source: TvMetadataSource) => {
|
||||
setTvSource(source);
|
||||
try {
|
||||
await invoke("save_tv_metadata_source", { source });
|
||||
} catch (err) {
|
||||
setStatus(`Couldn't save the TV source setting: ${err}`);
|
||||
window.clearTimeout((saveTvSource as any)._t);
|
||||
(saveTvSource as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
||||
}
|
||||
};
|
||||
|
||||
// "Fetch Data": resolve each already-Matched file's local guess against real online
|
||||
// metadata. Requires Match to have already populated either a series name + season/
|
||||
// episode (TV) or a movie title (movie) - that's the search query - so this is
|
||||
// deliberately layered on top of local matching, not a replacement for it. TV and
|
||||
// movie items are resolved via separate Tauri commands, run concurrently, and merged
|
||||
// back into a single set of counts/status message.
|
||||
// deliberately layered on top of local matching, not a replacement for it. TV items
|
||||
// resolve against whichever source `tvSource` currently picks (TMDB or TVmaze); movie
|
||||
// items always resolve against TMDB. Both run via separate Tauri commands,
|
||||
// concurrently, and merge back into a single set of counts/status message.
|
||||
const runFetch = async () => {
|
||||
if (hasTmdbToken === false) {
|
||||
setStatus("Add a free TMDB API token in Settings first, then Fetch Data will work.");
|
||||
setSettingsOpen(true);
|
||||
window.clearTimeout((runFetch as any)._t);
|
||||
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
||||
return;
|
||||
}
|
||||
|
||||
const tvTargets = items.filter(
|
||||
(i) => !i.isSample && i.mediaKind === "tv" && i.bindings?.n && i.bindings?.season && i.bindings?.episode
|
||||
);
|
||||
@@ -947,6 +985,18 @@ export default function RenamePanel() {
|
||||
return;
|
||||
}
|
||||
|
||||
// TVmaze needs no token at all, so a TMDB token is only actually required when
|
||||
// there are movie targets (always TMDB) or TV targets while TMDB is the chosen
|
||||
// source - a TV-only batch on TVmaze can run with no TMDB token configured at all.
|
||||
const needsTmdbToken = movieTargets.length > 0 || (tvTargets.length > 0 && tvSource === "tmdb");
|
||||
if (needsTmdbToken && hasTmdbToken === false) {
|
||||
setStatus("Add a free TMDB API token in Settings first, then Fetch Data will work.");
|
||||
setSettingsOpen(true);
|
||||
window.clearTimeout((runFetch as any)._t);
|
||||
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
||||
return;
|
||||
}
|
||||
|
||||
setFetching(true);
|
||||
try {
|
||||
const [tvResults, movieResults] = await Promise.all([
|
||||
@@ -1048,7 +1098,7 @@ export default function RenamePanel() {
|
||||
}
|
||||
|
||||
const parts = [`Fetched real metadata for ${found} of ${targetCount} file${targetCount === 1 ? "" : "s"}`];
|
||||
if (notFound > 0) parts.push(`${notFound} not found on TMDB`);
|
||||
if (notFound > 0) parts.push(`${notFound} not found`);
|
||||
if (failed > 0) parts.push(`${failed} failed`);
|
||||
setStatus(parts.join(", ") + ".");
|
||||
} catch (err) {
|
||||
@@ -1239,7 +1289,7 @@ export default function RenamePanel() {
|
||||
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
|
||||
<span className="list-button-gap" />
|
||||
<ListButton icon={<FetchIcon />} label="Fetch Data" onClick={runFetch} disabled={fetching} />
|
||||
<ListButton icon={<KeyIcon />} label="TMDB Settings" onClick={openSettings} />
|
||||
<ListButton icon={<KeyIcon />} label="Fetch Data Settings" onClick={openSettings} />
|
||||
<ListButton icon={<GearIcon />} label="Naming Scheme" onClick={() => setEditingFormat((v) => !v)} />
|
||||
</>
|
||||
}
|
||||
@@ -1312,8 +1362,10 @@ export default function RenamePanel() {
|
||||
like resolution, source (BluRay/HDTV/WEB-DL/…), video/audio codec, and release group — none of this
|
||||
needs a network call, since it's all parsed straight from the filename itself (Aurora Curator has no video/audio
|
||||
file prober, so a tag that isn't in the filename text just won't be available). Fetch Data resolves the
|
||||
season/episode or movie guess against real TMDB metadata for the real title, episode title, and
|
||||
air/release date — needs a free TMDB API token set in <strong>TMDB Settings</strong> first. A batch can
|
||||
season/episode or movie guess against real online metadata for the real title, episode title, and
|
||||
air/release date. TV episodes can come from either TMDB or TVmaze — pick which in{" "}
|
||||
<strong>Fetch Data Settings</strong> (TVmaze is free and needs no token at all); movies always use TMDB
|
||||
and need a free API token set there first. A batch can
|
||||
freely mix TV and movie files — each file only fills in the fields that apply to it. FileBot's own short
|
||||
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
|
||||
@@ -1431,13 +1483,27 @@ export default function RenamePanel() {
|
||||
{settingsOpen && (
|
||||
<div className="modal-overlay" onClick={() => !savingSettings && setSettingsOpen(false)}>
|
||||
<div className="modal-card modal-card-compact" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="modal-title">TMDB Settings</h2>
|
||||
<h2 className="modal-title">Fetch Data Settings</h2>
|
||||
<span className="modal-field-label">TV episode source</span>
|
||||
<div className="action-segmented" role="radiogroup" aria-label="Which source Fetch Data uses for TV episodes">
|
||||
{(["tmdb", "tvmaze"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={`action-segmented-option${tvSource === s ? " selected" : ""}`}
|
||||
onClick={() => saveTvSource(s)}
|
||||
>
|
||||
{TV_SOURCE_LABELS[s].button}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="modal-subtitle">{TV_SOURCE_LABELS[tvSource].description}</p>
|
||||
<p className="modal-subtitle">
|
||||
Fetch Data uses{" "}
|
||||
Movies always resolve against{" "}
|
||||
<a href="https://www.themoviedb.org/" target="_blank" rel="noreferrer">
|
||||
TheMovieDB
|
||||
</a>{" "}
|
||||
to resolve local guesses into real episode titles and air dates. Get a free{" "}
|
||||
regardless of the TV source above — TVmaze has no movie catalog. Get a free{" "}
|
||||
<strong>API Read Access Token</strong> from your TMDB account under Settings → API, then paste it below.
|
||||
The token is stored locally on this machine and never leaves it except to call TMDB directly.
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user