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:
Renamr Scaffold
2026-08-29 10:44:12 +00:00
parent 7097b1ede7
commit 5763c6313b
7 changed files with 729 additions and 96 deletions
+88 -22
View File
@@ -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>