Add TMDB metadata client and Fetch Data: real episode titles/air dates via TMDB, with a Settings modal for the API token
This commit is contained in:
@@ -144,3 +144,12 @@ export function FetchIcon() {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyIcon() {
|
||||
return (
|
||||
<svg {...{ ...base, width: 15, height: 15 }}>
|
||||
<circle cx="8" cy="15" r="4" />
|
||||
<path d="M11 12l9-9M16 5l3 3M13 8l3 3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,6 +127,12 @@
|
||||
background: #28515a;
|
||||
}
|
||||
|
||||
.list-icon-button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.load-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@@ -362,6 +368,39 @@
|
||||
padding: 0 18px;
|
||||
}
|
||||
|
||||
.modal-actions-split {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-card-compact {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.modal-subtitle a {
|
||||
color: var(--aurora-cyan);
|
||||
}
|
||||
|
||||
.modal-field-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--muted-text);
|
||||
margin: 4px 0 6px;
|
||||
}
|
||||
|
||||
.modal-text-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--surface-deep);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-family: ui-monospace, "SF Mono", monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
FetchIcon,
|
||||
GearIcon,
|
||||
HistoryIcon,
|
||||
KeyIcon,
|
||||
LoadIcon,
|
||||
PresetsIcon,
|
||||
RemoveIcon,
|
||||
@@ -61,6 +62,28 @@ interface RenameResult {
|
||||
outcome: RenameOutcome;
|
||||
}
|
||||
|
||||
// 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.
|
||||
interface FetchQuery {
|
||||
id: string;
|
||||
seriesQuery: string;
|
||||
season: number;
|
||||
episode: number;
|
||||
}
|
||||
|
||||
// Mirrors renamr-core's `FetchOutcome` (serde-tagged on "status").
|
||||
type FetchOutcome =
|
||||
| { status: "found"; seriesName: string; episodeTitle: string; airDate: string | null }
|
||||
| { status: "notFound" }
|
||||
| { status: "failed"; error: string };
|
||||
|
||||
interface FetchResult {
|
||||
id: string;
|
||||
outcome: FetchOutcome;
|
||||
}
|
||||
|
||||
function fileNameFromPath(path: string): string {
|
||||
const parts = path.split(/[\\/]/);
|
||||
return parts[parts.length - 1] ?? path;
|
||||
@@ -122,13 +145,15 @@ function ListButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
icon: JSX.Element;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button className="list-icon-button" title={label} onClick={onClick}>
|
||||
<button className="list-icon-button" title={label} onClick={onClick} disabled={disabled}>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
@@ -186,6 +211,20 @@ export default function RenamePanel() {
|
||||
const [renamePlan, setRenamePlan] = useState<RenamePlanEntry[] | null>(null);
|
||||
const [renamePlanSkipped, setRenamePlanSkipped] = useState(0);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
// TMDB token state for "Fetch Data". `null` = not checked yet, `false` = checked and
|
||||
// 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);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [tmdbTokenInput, setTmdbTokenInput] = useState("");
|
||||
const [savingSettings, setSavingSettings] = useState(false);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
invoke<string | null>("get_tmdb_token")
|
||||
.then((token) => setHasTmdbToken(!!token))
|
||||
.catch(() => setHasTmdbToken(false));
|
||||
}, []);
|
||||
|
||||
// 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.
|
||||
@@ -466,6 +505,125 @@ export default function RenamePanel() {
|
||||
setRenamePlan(null);
|
||||
};
|
||||
|
||||
const openSettings = () => {
|
||||
setTmdbTokenInput("");
|
||||
setSettingsOpen(true);
|
||||
};
|
||||
|
||||
const saveTmdbToken = async () => {
|
||||
const trimmed = tmdbTokenInput.trim();
|
||||
if (!trimmed) {
|
||||
// Nothing typed - just close, leaving whatever was already saved untouched.
|
||||
setSettingsOpen(false);
|
||||
return;
|
||||
}
|
||||
setSavingSettings(true);
|
||||
try {
|
||||
await invoke("save_tmdb_token", { token: trimmed });
|
||||
setHasTmdbToken(true);
|
||||
setTmdbTokenInput("");
|
||||
setSettingsOpen(false);
|
||||
setStatus("TMDB token saved.");
|
||||
window.clearTimeout((saveTmdbToken as any)._t);
|
||||
(saveTmdbToken as any)._t = window.setTimeout(() => setStatus(null), 3000);
|
||||
} catch (err) {
|
||||
setStatus(`Couldn't save the token: ${err}`);
|
||||
} finally {
|
||||
setSavingSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearTmdbToken = async () => {
|
||||
setSavingSettings(true);
|
||||
try {
|
||||
await invoke("save_tmdb_token", { token: "" });
|
||||
setHasTmdbToken(false);
|
||||
setTmdbTokenInput("");
|
||||
setStatus("TMDB token removed.");
|
||||
window.clearTimeout((clearTmdbToken as any)._t);
|
||||
(clearTmdbToken as any)._t = window.setTimeout(() => setStatus(null), 3000);
|
||||
} catch (err) {
|
||||
setStatus(`Couldn't remove the token: ${err}`);
|
||||
} finally {
|
||||
setSavingSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
// "Fetch Data": resolve each already-Matched file's local guess against real TMDB
|
||||
// metadata. Requires Match to have already populated a series name + season/episode
|
||||
// (that's the search query) - this is deliberately layered on top of local matching,
|
||||
// not a replacement for it.
|
||||
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 targets = items.filter((i) => !i.isSample && i.bindings?.n && i.bindings?.season && i.bindings?.episode);
|
||||
if (targets.length === 0) {
|
||||
setStatus("Run Match first — Fetch Data needs a guessed series name and season/episode to look up.");
|
||||
window.clearTimeout((runFetch as any)._t);
|
||||
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
||||
return;
|
||||
}
|
||||
|
||||
setFetching(true);
|
||||
try {
|
||||
const query: FetchQuery[] = targets.map((item) => ({
|
||||
id: item.id,
|
||||
seriesQuery: item.bindings!.n,
|
||||
season: Number(item.bindings!.season),
|
||||
episode: Number(item.bindings!.episode),
|
||||
}));
|
||||
const results = await invoke<FetchResult[]>("fetch_metadata", { items: query });
|
||||
const byId = new Map(results.map((r) => [r.id, r] as const));
|
||||
|
||||
// Compute counts from `results` directly, not inside the setItems updater below -
|
||||
// same rule as runMatching/confirmRename above (React doesn't run a functional
|
||||
// updater synchronously, so a counter mutated in there wouldn't be ready yet).
|
||||
const found = results.filter((r) => r.outcome.status === "found").length;
|
||||
const notFound = results.filter((r) => r.outcome.status === "notFound").length;
|
||||
const failed = results.filter((r) => r.outcome.status === "failed").length;
|
||||
|
||||
setItems((prev) =>
|
||||
prev.map((item) => {
|
||||
const result = byId.get(item.id);
|
||||
if (!result || result.outcome.status !== "found") return item;
|
||||
const { seriesName, episodeTitle, airDate } = result.outcome;
|
||||
return {
|
||||
...item,
|
||||
bindings: {
|
||||
...item.bindings,
|
||||
n: seriesName,
|
||||
t: episodeTitle,
|
||||
...(airDate ? { airdate: airDate } : {}),
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Showcase the episode title field, but only the first time and only if the user
|
||||
// hasn't already customized the naming scheme away from the Match-set default.
|
||||
if (expressionRef.current === "{n} - {s00e00}" && found > 0) {
|
||||
setExpression("{n} - {s00e00} - {t}");
|
||||
}
|
||||
|
||||
const parts = [`Fetched real metadata for ${found} of ${targets.length} file${targets.length === 1 ? "" : "s"}`];
|
||||
if (notFound > 0) parts.push(`${notFound} not found on TMDB`);
|
||||
if (failed > 0) parts.push(`${failed} failed`);
|
||||
setStatus(parts.join(", ") + ".");
|
||||
} catch (err) {
|
||||
setStatus(`Fetch failed: ${err}`);
|
||||
} finally {
|
||||
setFetching(false);
|
||||
window.clearTimeout((runFetch as any)._t);
|
||||
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const removeSelected = () => {
|
||||
if (!selected) return notImplemented("Excluding items");
|
||||
setItems((prev) => {
|
||||
@@ -552,7 +710,8 @@ export default function RenamePanel() {
|
||||
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => notImplemented("Reordering")} />
|
||||
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
|
||||
<span className="list-button-gap" />
|
||||
<ListButton icon={<FetchIcon />} label="Fetch Data" onClick={() => notImplemented("Online matching")} />
|
||||
<ListButton icon={<FetchIcon />} label="Fetch Data" onClick={runFetch} disabled={fetching} />
|
||||
<ListButton icon={<KeyIcon />} label="TMDB Settings" onClick={openSettings} />
|
||||
<ListButton icon={<GearIcon />} label="Naming Scheme" onClick={() => setEditingFormat((v) => !v)} />
|
||||
</>
|
||||
}
|
||||
@@ -574,8 +733,9 @@ export default function RenamePanel() {
|
||||
Rust naming-expression engine (Rhai-backed) through Tauri. Loaded files start with only{" "}
|
||||
<code>{"{name}"}</code>/<code>{"{ext}"}</code> (their own filename) — click <strong>Match</strong> to scan
|
||||
filenames locally for season/episode patterns and populate <code>{"{n}"}</code>/<code>{"{s00e00}"}</code>/
|
||||
<code>{"{season}"}</code>/<code>{"{episode}"}</code>. That's filename parsing only, no network calls;{" "}
|
||||
<strong>Fetch Data</strong> (online metadata matching) isn't built yet.
|
||||
<code>{"{season}"}</code>/<code>{"{episode}"}</code> (no network calls), then <strong>Fetch Data</strong> to
|
||||
resolve those guesses against real TMDB metadata and populate <code>{"{t}"}</code> (episode title) and{" "}
|
||||
<code>{"{airdate}"}</code> too — needs a free TMDB API token set in <strong>TMDB Settings</strong> first.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -619,6 +779,53 @@ export default function RenamePanel() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<p className="modal-subtitle">
|
||||
Fetch Data uses{" "}
|
||||
<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{" "}
|
||||
<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>
|
||||
<label htmlFor="tmdb-token" className="modal-field-label">
|
||||
API Read Access Token
|
||||
</label>
|
||||
<input
|
||||
id="tmdb-token"
|
||||
className="modal-text-input"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={tmdbTokenInput}
|
||||
onChange={(e) => setTmdbTokenInput(e.target.value)}
|
||||
placeholder={hasTmdbToken ? "•••••••••• (already saved — enter a new token to replace it)" : "Paste your TMDB token here"}
|
||||
/>
|
||||
<div className="modal-actions modal-actions-split">
|
||||
{hasTmdbToken ? (
|
||||
<button className="action-button" onClick={clearTmdbToken} disabled={savingSettings}>
|
||||
Remove Token
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="modal-actions">
|
||||
<button className="action-button" onClick={() => setSettingsOpen(false)} disabled={savingSettings}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="action-button primary" onClick={saveTmdbToken} disabled={savingSettings}>
|
||||
{savingSettings ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user