import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { invoke } from "@tauri-apps/api/core"; import { ClearIcon, DownIcon, FetchIcon, GearIcon, HistoryIcon, KeyIcon, LoadIcon, PresetsIcon, RemoveIcon, UpIcon, } from "./Icons"; interface Item { id: string; originalName: string; ext: string; bindings: Record | null; // null = no data to preview with (rendered muted) isSample: boolean; // What kind of media Match recognized this file as - "tv"/"movie" once Match (or // Fetch Data) has run, null before that or if nothing was recognized. Kept outside // `bindings` so it doesn't leak into the naming-scheme field-reference list. mediaKind: "tv" | "movie" | null; } interface FileEntry { path: string; name: string; // filename without extension ext: string; } // Result of the local, filename-only season/episode matcher (renamr-core's // `matching` module, exposed via the `guess_episode` Tauri command). No network calls - // this only recognizes patterns already present in the filename text itself. interface EpisodeGuess { seriesName: string; season: number; episode: number; } // Result of the local, filename-only movie title/year matcher (renamr-core's // `matching::guess_movie`, exposed via the `guess_movie` Tauri command). Tried as a // fallback whenever `guess_episode` finds no season/episode pattern - a release name // is either a TV episode or a movie, never both. interface MovieGuess { title: string; year: number | null; } // What runMatching resolved a file to, before it's written into the item's bindings. type MatchGuess = { kind: "tv"; episode: EpisodeGuess } | { kind: "movie"; movie: MovieGuess }; // Result of the local, filename-only release-tag matcher (renamr-core's // `matching::guess_release_info`, exposed via the `guess_release_info` Tauri command). // Independent of the TV/movie guess above - a release name carries (or doesn't carry) // these tags regardless of what kind of media it is, so this runs alongside whichever // of `guess_episode`/`guess_movie` applied. Every field is optional on its own. interface ReleaseInfo { resolution: string | null; source: string | null; videoCodec: string | null; audioCodec: string | null; group: string | null; } function pad2(n: number): string { return n.toString().padStart(2, "0"); } // One row of the on-disk rename plan shown in the confirmation modal before anything // touches the filesystem. `id`/`source` are the file's current full path. interface RenamePlanEntry { id: string; source: string; currentName: string; newName: string; } // Mirrors renamr-core's `RenameOutcome` (serde-tagged on "status"). Conflict policy is // fixed at skip - a colliding target is never overwritten, just reported. type RenameOutcome = | { status: "renamed"; newPath: string } | { status: "unchanged" } | { status: "skippedConflict"; existingPath: string } | { status: "failed"; error: string }; interface RenameResult { source: string; outcome: RenameOutcome; } // Mirrors renamr-core's `RenameAction` - what actually happens at each computed target. // "move" relocates the file in place (the only action Renamr had before); "copy" and // "symlink" both leave the source file exactly where it is and produce a new file // elsewhere, which is why only "move" updates the loaded item's own path/name (see // confirmRename below). type RenameAction = "move" | "copy" | "symlink"; // 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; } // Sent to `fetch_movie_metadata`: one file's local movie-title guess (from Match) to // resolve against real TMDB data. Mirrors FetchQuery, but for movies - a year is // optional since not every release name carries one. interface MovieFetchQuery { id: string; titleQuery: string; year: number | null; } // Mirrors renamr-core's `MovieFetchOutcome` (serde-tagged on "status"). type MovieFetchOutcome = | { status: "found"; title: string; releaseDate: string | null } | { status: "notFound" } | { status: "failed"; error: string }; interface MovieFetchResult { id: string; outcome: MovieFetchOutcome; } // Rename history / undo. Mirrors renamr-core's `history` module (serde-tagged on // "status" for the outcome, same convention as RenameOutcome/FetchOutcome above). interface HistoryEntry { source: string; newPath: string; } interface HistoryBatch { id: string; /// Unix epoch milliseconds - formatted client-side with `Date`. timestamp: number; // What action produced every entry in this batch. Older history entries recorded // before this field existed come back as "move" (the Rust side's serde default), // which is also correct - that was the only action that ever existed back then. action: RenameAction; entries: HistoryEntry[]; } type RevertOutcome = | { status: "reverted"; restoredPath: string } | { status: "skippedConflict"; existingPath: string } | { status: "failed"; error: string }; interface RevertResult { source: string; outcome: RevertOutcome; } function fileNameFromPath(path: string): string { const parts = path.split(/[\\/]/); return parts[parts.length - 1] ?? path; } // Shown until the user loads real files, so the two-pane layout and live naming-scheme // preview are visible immediately. Item 4 demonstrates the "no data" muted row style. // This is what full metadata-matched renaming will look like once the matching engine // (see the porting roadmap) is ported — real loaded files use their own filename fields // instead (see realFileBindings below) since there's no online identification yet. const SAMPLE_ITEMS: Item[] = [ { id: "sample-1", originalName: "Breaking.Bad.S05E14.720p.HDTV.x264-KILLERS.mkv", ext: "mkv", bindings: { n: "Breaking Bad", s00e00: "S05E14", t: "Ozymandias", "show name": "Breaking Bad", "season number": "5", "episode number": "14", "episode name": "Ozymandias", vf: "720p", source: "HDTV", vc: "x264", group: "KILLERS", resolution: "720p", "video codec": "x264", "release group": "KILLERS", }, isSample: true, mediaKind: "tv", }, { id: "sample-2", originalName: "Breaking.Bad.S05E15.720p.HDTV.x264-KILLERS.mkv", ext: "mkv", bindings: { n: "Breaking Bad", s00e00: "S05E15", t: "Granite State", "show name": "Breaking Bad", "season number": "5", "episode number": "15", "episode name": "Granite State", vf: "720p", source: "HDTV", vc: "x264", group: "KILLERS", resolution: "720p", "video codec": "x264", "release group": "KILLERS", }, isSample: true, mediaKind: "tv", }, { id: "sample-3", originalName: "The.Office.US.S03E01.DVDRip.XviD-SAiNTS.avi", ext: "avi", bindings: { n: "The Office (US)", s00e00: "S03E01", t: "Gay Witch Hunt", "show name": "The Office (US)", "season number": "3", "episode number": "01", "episode name": "Gay Witch Hunt", source: "DVDRip", vc: "XviD", group: "SAiNTS", "video codec": "XviD", "release group": "SAiNTS", }, isSample: true, mediaKind: "tv", }, { id: "sample-4", originalName: "random_video_file.mp4", ext: "mp4", bindings: null, isSample: true, mediaKind: null, }, ]; // The user's own preferred naming scheme (their words: "I personally like {show name} - // {season number}x{episode number} - {episode name}") - this is Renamr's default now, // not just an example. `{show name}`/`{season number}`/etc. are multi-word plain-field // binding names, which work via renamr-core's ExpressionFormat plain-field-name // fallback (ExpressionError-free even though they aren't valid Rhai identifiers). const SAMPLE_EXPRESSION = "{show name} - {season number}x{episode number} - {episode name}"; // The matching engine isn't ported yet, so real loaded files only have their own // filename-derived fields to preview with (mirrors the original's plain FileNameFormatter, // used whenever a file has no online match). const REAL_FILE_EXPRESSION = "{name}"; // What Match alone can fill in (no episode title yet - that needs Fetch Data). const MATCHED_EXPRESSION = "{show name} - {season number}x{episode number}"; // What Match + Fetch Data together can fill in. const FETCHED_EXPRESSION = "{show name} - {season number}x{episode number} - {episode name}"; // Copy for each RenameAction, used in the confirmation modal's segmented selector, // subtitle, confirm-button label, and the post-confirm status message. const RENAME_ACTION_LABELS: Record< RenameAction, { button: string; verb: string; pastTense: string; progressive: string } > = { move: { button: "Rename", verb: "rename", pastTense: "Renamed", progressive: "Renaming…" }, copy: { button: "Copy", verb: "copy", pastTense: "Copied", progressive: "Copying…" }, symlink: { button: "Symlink", verb: "create symlinks for", pastTense: "Symlinked", progressive: "Creating symlinks…", }, }; function realFileToItem(entry: FileEntry): Item { return { id: entry.path, originalName: entry.ext ? `${entry.name}.${entry.ext}` : entry.name, ext: entry.ext, bindings: { name: entry.name, ext: entry.ext }, isSample: false, mediaKind: null, // unknown until Match runs }; } function ListButton({ icon, label, onClick, disabled, }: { icon: JSX.Element; label: string; onClick: () => void; disabled?: boolean; }) { return ( ); } function LoadMenuButton({ onFiles, onFolder }: { onFiles: () => void; onFolder: () => void }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; const onOutside = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener("mousedown", onOutside); return () => document.removeEventListener("mousedown", onOutside); }, [open]); return (
} label="Load Files" onClick={() => setOpen((v) => !v)} /> {open && (
)}
); } export default function RenamePanel() { const [expression, setExpression] = useState(SAMPLE_EXPRESSION); const [editingFormat, setEditingFormat] = useState(false); const [items, setItems] = useState(SAMPLE_ITEMS); const [selected, setSelected] = useState(SAMPLE_ITEMS[0].id); const [computedNames, setComputedNames] = useState>({}); const [computeError, setComputeError] = useState(null); const [status, setStatus] = useState(null); // Non-null while the "confirm before touching disk" modal is open. const [renamePlan, setRenamePlan] = useState(null); const [renamePlanSkipped, setRenamePlanSkipped] = useState(0); const [renaming, setRenaming] = useState(false); // What to actually do at each computed target - chosen in the confirmation modal, // remembered across modal opens within the session (defaults to "move", matching // Renamr's original and only behavior before copy/symlink existed). const [renameAction, setRenameAction] = useState("move"); // 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(null); const [settingsOpen, setSettingsOpen] = useState(false); const [tmdbTokenInput, setTmdbTokenInput] = useState(""); const [savingSettings, setSavingSettings] = useState(false); const [fetching, setFetching] = useState(false); // Rename history / undo. `null` batches = not loaded yet (also used to show a // loading state the first time the History panel is opened). const [historyOpen, setHistoryOpen] = useState(false); const [historyBatches, setHistoryBatches] = useState(null); const [undoingBatchId, setUndoingBatchId] = useState(null); useEffect(() => { invoke("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. const expressionRef = useRef(expression); useEffect(() => { expressionRef.current = expression; }, [expression]); useEffect(() => { let cancelled = false; Promise.all( items.map(async (item) => { if (!item.bindings) return [item.id, null] as const; const rendered = await invoke("render_expression", { expression, bindings: item.bindings, }); return [item.id, `${rendered}${item.ext ? `.${item.ext}` : ""}`] as const; }) ) .then((entries) => { if (cancelled) return; const next: Record = {}; for (const [id, name] of entries) { if (name) next[id] = name; } setComputedNames(next); setComputeError(null); }) .catch((err) => { if (cancelled) return; setComputeError(String(err)); }); return () => { cancelled = true; }; }, [expression, items]); const notImplemented = (feature: string) => { setStatus(`${feature} isn't implemented yet — see the porting roadmap in the README.`); window.clearTimeout((notImplemented as any)._t); (notImplemented as any)._t = window.setTimeout(() => setStatus(null), 3200); }; const addRealFiles = (entries: FileEntry[]) => { if (entries.length === 0) return; // Only auto-switch the naming scheme the first time real files replace the demo, // and only if the user hasn't already customized it. if (expressionRef.current === SAMPLE_EXPRESSION) { setExpression(REAL_FILE_EXPRESSION); } setItems((prev) => { const real = prev.filter((i) => !i.isSample); const existing = new Set(real.map((i) => i.id)); const added = entries.map(realFileToItem).filter((i) => !existing.has(i.id)); const next = [...real, ...added]; if (added.length > 0) setSelected(added[0].id); return next; }); }; const loadFiles = () => { invoke("pick_files") .then(addRealFiles) .catch((err) => setStatus(`Couldn't open the file picker: ${err}`)); }; const loadFolder = () => { invoke("pick_folder") .then(addRealFiles) .catch((err) => setStatus(`Couldn't open the folder picker: ${err}`)); }; // OS drag-and-drop of files/folders onto the window. useEffect(() => { let unlisten: (() => void) | undefined; let cancelled = false; import("@tauri-apps/api/webview") .then(({ getCurrentWebview }) => getCurrentWebview().onDragDropEvent((event) => { if (event.payload.type === "drop") { invoke("expand_dropped_paths", { paths: event.payload.paths }) .then(addRealFiles) .catch((err) => setStatus(`Couldn't read dropped files: ${err}`)); } }) ) .then((fn) => { if (cancelled) fn(); else unlisten = fn; }) .catch(() => { // Not running inside Tauri (e.g. a plain browser preview) - drag-and-drop // just won't be available there; the Load button still works via invoke. }); return () => { cancelled = true; unlisten?.(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Local, filename-based season/episode/movie detection (renamr-core's `matching` // module). Tries `guess_episode` first; when a filename carries no season/episode // marker, falls back to `guess_movie` (a release name is either a TV episode or a // movie, never both - see the doc comment on `guess_movie` in renamr-core). Populates // TV bindings (`{n}`/`{s00e00}`/`{season}`/`{episode}`/etc.) or movie bindings // (`{n}`/`{y}`/etc.) accordingly - no network calls, no metadata lookup, so it's // distinct from (and much cheaper than) "Fetch Data" online matching. const runMatching = async () => { const targets = items.filter((i) => !i.isSample); if (targets.length === 0) { setStatus("Load some files first, then Match will scan their names for season/episode or movie info."); window.clearTimeout((runMatching as any)._t); (runMatching as any)._t = window.setTimeout(() => setStatus(null), 3200); return; } const results: Array = await Promise.all( targets.map(async (item): Promise => { // Release tags (resolution/source/codecs/group) are independent of TV-vs-movie, // so kick that guess off alongside whichever of the two below applies. const releasePromise = invoke("guess_release_info", { filename: item.originalName, }).catch(() => null); let mediaGuess: MatchGuess | null = null; try { const episode = await invoke("guess_episode", { filename: item.originalName, }); if (episode) mediaGuess = { kind: "tv", episode }; } catch { // Fall through and try a movie guess instead. } if (!mediaGuess) { try { const movie = await invoke("guess_movie", { filename: item.originalName, }); if (movie) mediaGuess = { kind: "movie", movie }; } catch { // Neither guess worked - leave this item unmatched. } } return [item.id, mediaGuess, await releasePromise]; }) ); const guesses = new Map(results.map(([id, guess]) => [id, guess] as const)); const releaseGuesses = new Map(results.map(([id, , release]) => [id, release] as const)); // Count matches from the results directly, not inside the setItems updater below: // React doesn't run a functional updater synchronously, so a counter mutated in // there wouldn't be ready yet for the status message/auto-upgrade check right after. const matchedTv = results.filter(([, guess]) => guess?.kind === "tv").length; const matchedMovie = results.filter(([, guess]) => guess?.kind === "movie").length; const matched = matchedTv + matchedMovie; const tagged = results.filter(([, , release]) => release !== null).length; setItems((prev) => prev.map((item) => { const guess = guesses.get(item.id); const release = releaseGuesses.get(item.id); // Release-tag fields apply on top of whatever the TV/movie guess below // contributes (or nothing at all, for a file with no season/episode or movie // title recognized but a quality tag in its name). const releaseBindings: Record | null = release ? { ...(release.resolution ? { vf: release.resolution, resolution: release.resolution } : {}), ...(release.source ? { source: release.source } : {}), ...(release.videoCodec ? { vc: release.videoCodec, "video codec": release.videoCodec } : {}), ...(release.audioCodec ? { ac: release.audioCodec, "audio codec": release.audioCodec } : {}), ...(release.group ? { group: release.group, "release group": release.group } : {}), } : null; if (!guess) { return releaseBindings ? { ...item, bindings: { ...item.bindings, ...releaseBindings } } : item; } if (guess.kind === "tv") { const { episode } = guess; const seriesName = episode.seriesName || item.bindings?.name || item.originalName; return { ...item, mediaKind: "tv", bindings: { ...item.bindings, // FileBot-style short names, for compatibility with expressions that use them. n: seriesName, s00e00: `S${pad2(episode.season)}E${pad2(episode.episode)}`, season: String(episode.season), episode: String(episode.episode), // Human-readable aliases (this is what the default naming scheme uses). "show name": seriesName, "season number": String(episode.season), "episode number": pad2(episode.episode), ...releaseBindings, }, }; } const { movie } = guess; const title = movie.title || item.bindings?.name || item.originalName; return { ...item, mediaKind: "movie", bindings: { ...item.bindings, // FileBot-style short name, for compatibility (same `{n}` field TV uses). n: title, ...(movie.year ? { y: String(movie.year) } : {}), // Human-readable aliases. "movie name": title, ...(movie.year ? { year: String(movie.year) } : {}), ...releaseBindings, }, }; }) ); // Showcase the newly-populated fields, but only the first time and only if the // user hasn't already customized the naming scheme away from the plain-filename // default. Movies deliberately don't drive this auto-upgrade - a single "best // expression" heuristic doesn't generalize across a batch mixing TV and movies. if (expressionRef.current === REAL_FILE_EXPRESSION && matchedTv > 0) { setExpression(MATCHED_EXPRESSION); } setStatus( matched > 0 ? `Matched ${matched} of ${targets.length} file${targets.length === 1 ? "" : "s"} (${matchedTv} TV, ${matchedMovie} movie${matchedMovie === 1 ? "" : "s"}${tagged > 0 ? `, ${tagged} with release tags` : ""}; filename-based, local only).` : "No season/episode or movie title recognized in the loaded filenames." ); window.clearTimeout((runMatching as any)._t); (runMatching as any)._t = window.setTimeout(() => setStatus(null), 4000); }; // Step 1 of Rename: build the plan (old path -> new filename) from the live preview // that's already on screen, and open the confirmation modal. Nothing on disk is // touched here - only `confirmRename` below actually calls the Rust rename command, // and only after the user explicitly approves the plan they can see. const prepareRename = () => { const targets = items.filter((i) => !i.isSample); if (targets.length === 0) { setStatus("Load some real files first — Rename needs files on disk to work with."); window.clearTimeout((prepareRename as any)._t); (prepareRename as any)._t = window.setTimeout(() => setStatus(null), 3200); return; } const plan: RenamePlanEntry[] = []; let skippedNoPreview = 0; for (const item of targets) { const newName = computedNames[item.id]; if (!newName) { skippedNoPreview += 1; continue; } if (newName === item.originalName) continue; // already correct, nothing to do plan.push({ id: item.id, source: item.id, currentName: item.originalName, newName }); } if (plan.length === 0) { setStatus( skippedNoPreview > 0 ? "Nothing to rename yet — the naming scheme couldn't be computed for those files (check the naming scheme for errors)." : "Nothing to rename — the computed names already match the current filenames." ); window.clearTimeout((prepareRename as any)._t); (prepareRename as any)._t = window.setTimeout(() => setStatus(null), 4000); return; } setRenamePlanSkipped(skippedNoPreview); setRenamePlan(plan); }; // Step 2: the user approved the plan (and chose an action) shown in the modal - // actually run it on disk via the Rust `execute_rename` command, then reconcile // local state and report the outcome. Only "move" relocates the source file, so only // "move" updates a loaded item's own id/name/bindings to point at the new path - // "copy"/"symlink" leave the source exactly where it was and produce an additional // file elsewhere that Renamr doesn't separately track as a loaded item. const confirmRename = async () => { if (!renamePlan || renamePlan.length === 0) return; setRenaming(true); try { const results = await invoke("execute_rename", { items: renamePlan.map((p) => ({ source: p.source, newName: p.newName })), action: renameAction, }); const bySource = new Map(results.map((r) => [r.source, r] as const)); // Compute counts from `results` directly, not inside the setItems updater below: // React doesn't run a functional updater synchronously, so counters mutated in // there aren't ready yet when building the status message right after (this bit // runMatching earlier - same fix applies here). const succeeded = results.filter((r) => r.outcome.status === "renamed").length; const skipped = results.filter((r) => r.outcome.status === "skippedConflict").length; const failed = results.filter((r) => r.outcome.status === "failed").length; if (renameAction === "move") { setItems((prev) => prev.map((item) => { const result = bySource.get(item.id); if (!result) return item; if (result.outcome.status === "renamed") { const newFileName = fileNameFromPath(result.outcome.newPath); const [base, ext] = splitExt(newFileName); return { ...item, id: result.outcome.newPath, originalName: newFileName, ext, bindings: item.bindings ? { ...item.bindings, name: base, ext } : item.bindings, }; } return item; }) ); // Keep the current selection pointing at the same logical file if its id // (path) changed underneath it. setSelected((prevSelected) => { if (!prevSelected) return prevSelected; const result = bySource.get(prevSelected); return result?.outcome.status === "renamed" ? result.outcome.newPath : prevSelected; }); } const parts = [`${RENAME_ACTION_LABELS[renameAction].pastTense} ${succeeded} file${succeeded === 1 ? "" : "s"}`]; if (skipped > 0) parts.push(`${skipped} skipped (name already exists)`); if (failed > 0) parts.push(`${failed} failed`); setStatus(parts.join(", ") + "."); } catch (err) { setStatus(`Rename failed: ${err}`); } finally { setRenaming(false); setRenamePlan(null); window.clearTimeout((confirmRename as any)._t); (confirmRename as any)._t = window.setTimeout(() => setStatus(null), 5000); } }; const cancelRename = () => { if (renaming) return; setRenamePlan(null); }; // Per-item exclude, inside the confirmation modal itself: drops one row from the // plan without closing the modal or having to cancel and re-select beforehand. Every // bit of the modal's copy (file counts, the confirm button's label) reads directly // from `renamePlan.length`, so removing a row here updates all of it for free. const excludeFromPlan = (id: string) => { if (renaming) return; setRenamePlan((prev) => (prev ? prev.filter((p) => p.id !== id) : prev)); }; // Rename history / undo - Renamr's only safety net beyond the confirm-before-execute // step, since a filesystem rename has no OS-level undo. `execute_rename` records a // batch of every successfully-renamed file after each Rename; this panel lists those // batches and can revert one back to its original paths. const openHistory = () => { setHistoryOpen(true); invoke("list_rename_history") .then(setHistoryBatches) .catch((err) => { setHistoryBatches([]); setStatus(`Couldn't load rename history: ${err}`); }); }; const closeHistory = () => { if (undoingBatchId) return; setHistoryOpen(false); }; const undoBatch = async (batch: HistoryBatch) => { setUndoingBatchId(batch.id); try { const results = await invoke("undo_rename_batch", { batchId: batch.id }); // Compute counts from `results` directly, not inside the setItems updater below - // same rule as runMatching/confirmRename/runFetch above (React doesn't run a // functional updater synchronously, so a counter mutated in there wouldn't be // ready yet when building the status message right after). const bySource = new Map(results.map((r) => [r.source, r] as const)); const reverted = results.filter((r) => r.outcome.status === "reverted").length; const skipped = results.filter((r) => r.outcome.status === "skippedConflict").length; const failed = results.filter((r) => r.outcome.status === "failed").length; // Reconcile any currently-loaded item that was part of this batch back to its // pre-rename path/filename - mirrors confirmRename's post-rename reconciliation. setItems((prev) => prev.map((item) => { const result = bySource.get(item.id); if (!result || result.outcome.status !== "reverted") return item; const restoredFileName = fileNameFromPath(result.outcome.restoredPath); const [base, ext] = splitExt(restoredFileName); return { ...item, id: result.outcome.restoredPath, originalName: restoredFileName, ext, bindings: item.bindings ? { ...item.bindings, name: base, ext } : item.bindings, }; }) ); setSelected((prevSelected) => { if (!prevSelected) return prevSelected; const result = bySource.get(prevSelected); return result?.outcome.status === "reverted" ? result.outcome.restoredPath : prevSelected; }); setHistoryBatches((prev) => (prev ? prev.filter((b) => b.id !== batch.id) : prev)); const parts = [`Reverted ${reverted} file${reverted === 1 ? "" : "s"}`]; if (skipped > 0) parts.push(`${skipped} skipped (something else is there now)`); if (failed > 0) parts.push(`${failed} failed`); setStatus(parts.join(", ") + "."); } catch (err) { setStatus(`Undo failed: ${err}`); } finally { setUndoingBatchId(null); window.clearTimeout((undoBatch as any)._t); (undoBatch as any)._t = window.setTimeout(() => setStatus(null), 5000); } }; 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 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. 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 ); const movieTargets = items.filter((i) => !i.isSample && i.mediaKind === "movie" && i.bindings?.n); const targetCount = tvTargets.length + movieTargets.length; if (targetCount === 0) { setStatus("Run Match first — Fetch Data needs a guessed series/episode or movie title to look up."); 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([ tvTargets.length > 0 ? invoke("fetch_metadata", { items: tvTargets.map( (item): FetchQuery => ({ id: item.id, seriesQuery: item.bindings!.n, season: Number(item.bindings!.season), episode: Number(item.bindings!.episode), }) ), }) : Promise.resolve([]), movieTargets.length > 0 ? invoke("fetch_movie_metadata", { items: movieTargets.map( (item): MovieFetchQuery => ({ id: item.id, titleQuery: item.bindings!.n, year: item.bindings!.y ? Number(item.bindings!.y) : null, }) ), }) : Promise.resolve([]), ]); const tvById = new Map(tvResults.map((r) => [r.id, r] as const)); const movieById = new Map(movieResults.map((r) => [r.id, r] as const)); // Compute counts from the raw 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 = tvResults.filter((r) => r.outcome.status === "found").length + movieResults.filter((r) => r.outcome.status === "found").length; const notFound = tvResults.filter((r) => r.outcome.status === "notFound").length + movieResults.filter((r) => r.outcome.status === "notFound").length; const failed = tvResults.filter((r) => r.outcome.status === "failed").length + movieResults.filter((r) => r.outcome.status === "failed").length; setItems((prev) => prev.map((item) => { const tvResult = tvById.get(item.id); if (tvResult && tvResult.outcome.status === "found") { const { seriesName, episodeTitle, airDate } = tvResult.outcome; return { ...item, bindings: { ...item.bindings, // FileBot-style short names, for compatibility. n: seriesName, t: episodeTitle, ...(airDate ? { airdate: airDate } : {}), // Human-readable aliases. "show name": seriesName, "episode name": episodeTitle, }, }; } const movieResult = movieById.get(item.id); if (movieResult && movieResult.outcome.status === "found") { const { title, releaseDate } = movieResult.outcome; // Only overwrite the locally-guessed year if Match didn't already find one // in the filename itself - a filename-carried year is usually more precise // than TMDB's release date for disambiguating remakes. const guessedYear = item.bindings?.y; const releaseYear = releaseDate?.slice(0, 4); return { ...item, bindings: { ...item.bindings, // FileBot-style short name, for compatibility (same `{n}` field TV uses). n: title, // Reuses the same `{airdate}` field TV uses - matches FileBot's own // unified binding, which covers both episode air dates and movie // release dates. ...(releaseDate ? { airdate: releaseDate } : {}), ...(!guessedYear && releaseYear ? { y: releaseYear, year: releaseYear } : {}), // Human-readable alias. "movie name": title, }, }; } return item; }) ); // 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 === MATCHED_EXPRESSION && found > 0) { setExpression(FETCHED_EXPRESSION); } const parts = [`Fetched real metadata for ${found} of ${targetCount} file${targetCount === 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) => { const idx = prev.findIndex((i) => i.id === selected); const next = prev.filter((i) => i.id !== selected); const fallback = next[Math.min(idx, next.length - 1)]; setSelected(fallback ? fallback.id : null); return next; }); }; const clearAll = () => { setItems([]); setSelected(null); }; const matchedCount = useMemo(() => items.filter((i) => i.bindings).length, [items]); const renderOriginalRow = (item: Item) => ( setSelected(item.id)} text={item.originalName} badge={item.ext} /> ); const renderNameRow = (item: Item) => { const name = computedNames[item.id]; const [base, ext] = name ? splitExt(name) : [null, item.ext]; return ( setSelected(item.id)} text={item.bindings ? base ?? "…" : "No data"} badge={ext} /> ); }; return (
} label="Align Down" onClick={() => notImplemented("Reordering")} /> } label="Align Up" onClick={() => notImplemented("Reordering")} /> } label="Exclude Selected" onClick={removeSelected} /> } label="Clear" onClick={clearAll} /> } label="History" onClick={openHistory} /> } label="Presets" onClick={() => notImplemented("Presets")} /> } />
} label="Align Down" onClick={() => notImplemented("Reordering")} /> } label="Align Up" onClick={() => notImplemented("Reordering")} /> } label="Fetch Data" onClick={runFetch} disabled={fetching} /> } label="TMDB Settings" onClick={openSettings} /> } label="Naming Scheme" onClick={() => setEditingFormat((v) => !v)} /> } />
{editingFormat && (
setExpression(e.target.value)} /> {computeError &&

{computeError}

}

This field is fully yours to edit — type any combination of literal text and{" "} {"{field}"} placeholders and the preview updates live against {matchedCount} of{" "} {items.length} file{items.length === 1 ? "" : "s"} (the real Rust naming-expression engine, through Tauri). Every field below works as a plain {"{field name}"}, spaces and all — no coding required — plus the whole thing also accepts real{" "} Rhai {" "} script for anyone who wants it (e.g. {"{show name.to_upper()}"}).

Always available {"{name}"} {"{ext}"}
TV, after Match (local, offline) {"{show name}"} {"{season number}"} {"{episode number}"}
TV, after Fetch Data (real TMDB metadata) {"{episode name}"} {"{airdate}"}
Movies, after Match or Fetch Data {"{movie name}"} {"{year}"} {"{airdate}"}
Release tags, after Match (TV or movies, local, offline) {"{resolution}"} {"{source}"} {"{video codec}"} {"{audio codec}"} {"{release group}"}

Match scans filenames locally for season/episode or movie title/year patterns, plus release-tag text 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 (Renamr 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 TMDB Settings 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 ({"{n}"}, {"{s00e00}"}, {"{t}"}, {"{y}"}, {"{vf}"}, {"{vc}"}, {"{ac}"}, {"{group}"}, etc.) still work too, side by side with the names above.

Use {"/"} in the scheme to organize files into subfolders as part of renaming, e.g.{" "} {"{show name}/{episode name}"} puts each show in its own folder. Missing folders are created automatically; nothing outside the file's own folder tree is ever touched.

)} {status &&
{status}
} {renamePlan && (
e.stopPropagation()}>

Confirm Rename

{(["move", "copy", "symlink"] as const).map((a) => ( ))}

This will {RENAME_ACTION_LABELS[renameAction].verb} {renamePlan.length} file {renamePlan.length === 1 ? "" : "s"} on disk right now. Review the list below before continuing — use the ✕ on any row to drop just that file from this batch, or undo the whole batch afterward from the History panel. {renameAction !== "move" && " The original file is left exactly where it is; only the new copy/symlink is created."} {renamePlanSkipped > 0 && ` (${renamePlanSkipped} other file${renamePlanSkipped === 1 ? "" : "s"} excluded: no valid preview.)`} {renamePlan.some((p) => p.newName.includes("/")) && ` Some of these also ${ renameAction === "move" ? "move" : "place" } the file into a new subfolder — any missing folders are created automatically.`}

{renamePlan.length === 0 ? (
Every file was excluded — nothing left to rename.
) : ( renamePlan.map((p) => (
{p.currentName} → {p.newName}
)) )}

If a computed name already exists on disk, that file is skipped rather than overwritten.

)} {settingsOpen && (
!savingSettings && setSettingsOpen(false)}>
e.stopPropagation()}>

TMDB Settings

Fetch Data uses{" "} TheMovieDB {" "} to resolve local guesses into real episode titles and air dates. Get a free{" "} API Read Access Token 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.

setTmdbTokenInput(e.target.value)} placeholder={hasTmdbToken ? "•••••••••• (already saved — enter a new token to replace it)" : "Paste your TMDB token here"} />
{hasTmdbToken ? ( ) : ( )}
)} {historyOpen && (
e.stopPropagation()}>

Rename History

Every completed batch is logged here so it can be undone. For a Move batch, undoing moves each file back to its original path — if something else now exists there, that one file is skipped rather than overwritten. For a Copy or Symlink batch, undoing just removes the copy/symlink that was created; the original file was never touched, so there's nothing to move back. The log keeps the most recent 50 operations.

{historyBatches === null ? (

Loading…

) : historyBatches.length === 0 ? (

No renames yet — this fills in after you use Rename.

) : (
{historyBatches.map((batch) => (
{new Date(batch.timestamp).toLocaleString()} {batch.entries.length} file{batch.entries.length === 1 ? "" : "s"} {batch.action !== "move" && ` · ${RENAME_ACTION_LABELS[batch.action].button}`}
{batch.entries.map((entry, i) => (
{fileNameFromPath(entry.newPath)} → {fileNameFromPath(entry.source)}
))}
))}
)}
)}
); } function ListCard({ title, items, renderRow, emptyHint, buttonRow, }: { title: string; items: Item[]; renderRow: (item: Item) => ReactNode; emptyHint: string; buttonRow: ReactNode; }) { return (
{title}
{items.length === 0 ?
{emptyHint}
: items.map(renderRow)}
{buttonRow}
); } function Row({ text, badge, selected, muted, onClick, }: { text: string; badge: string | null; selected: boolean; muted: boolean; onClick: () => void; }) { return (
{text} {badge && {badge}}
); } function splitExt(name: string): [string, string] { const i = name.lastIndexOf("."); if (i <= 0) return [name, ""]; return [name.slice(0, i), name.slice(i + 1)]; }