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:
@@ -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