From bfcec9135a68c055dcfc18bacbaad7b60d5a2b18 Mon Sep 17 00:00:00 2001 From: Renamr Scaffold Date: Sat, 29 Aug 2026 17:01:15 +0000 Subject: [PATCH] Add Filter panel: narrow a loaded batch by extension, kind, filename, and season Fully local, and adds no new Rust/Tauri commands at all - reuses pick_files plus the same guess_episode/guess_movie/guess_release_info local matchers "Match" already calls in the Rename panel, just applied to a second, independently-loaded file list and rendered as toggleable filter criteria instead of a rename preview. New src/components/FilterPanel.tsx/.css: Load Files scans the batch immediately, toggleable chips appear for every extension and media kind actually present, a filename-contains text field and a season number field (TV only) narrow further, and Copy File Paths exports whatever currently matches to the clipboard. Verified with a 21-check Playwright script against npm run dev (chip toggling, the filename/season filters, the no-match empty state, and the clipboard export). --- README.md | 44 ++++- src/App.tsx | 3 + src/components/FilterPanel.css | 136 +++++++++++++++ src/components/FilterPanel.tsx | 303 +++++++++++++++++++++++++++++++++ src/main.tsx | 1 + 5 files changed, 481 insertions(+), 6 deletions(-) create mode 100644 src/components/FilterPanel.css create mode 100644 src/components/FilterPanel.tsx diff --git a/README.md b/README.md index 06585cf..0d36afd 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,10 @@ whichever TV source (TMDB/TVmaze/OMDb) is already configured in Fetch Data Setti separate credentials needed; see "Episodes" below. A new SFV panel computes and verifies CRC32/MD5/SHA1 checksums for a loaded set of files, and reads and writes the classic `.sfv`/`.md5`/`.sha1` sidecar-file formats — entirely local, no network access or -credentials of any kind; see "SFV" below. See "Porting roadmap" below. +credentials of any kind; see "SFV" below. A new Filter panel loads a batch of files and +narrows it down by extension, TV/Movie/Unmatched kind, filename text, and (for TV) season +— all computed from the same local, filename-only matching "Match" uses, with a Copy File +Paths export for whatever's left; see "Filter" below. See "Porting roadmap" below. The naming scheme is fully user-editable from the app (the "Naming Scheme" button next to the New Names list) — the default is `{show name} - {season number}x{episode @@ -244,7 +247,14 @@ src/ React + TypeScript UI (Vite). Faithful recreation of the checksum file and verify the files it lists against what's actually on disk (OK/mismatch/missing per entry) — no settings modal at all, since the feature needs no credentials or network - access. + access. `FilterPanel.tsx` is the fourth: load a set of files and + narrow them down with toggleable extension/kind chips, a + filename-contains text field, and a season number field (TV + only) — adds no new Rust/Tauri commands at all, reusing + `pick_files`/`guess_episode`/`guess_movie`/`guess_release_info` + exactly as the Rename panel's own "Match" does, just applied to + a second, independently-loaded file list and rendered as + criteria instead of a rename preview. 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. @@ -441,6 +451,26 @@ resolved against the real files sitting next to it on disk and reported as **OK* referenced file isn't there). Checksum comparison is case-insensitive, since some tools write hex digests in uppercase and others in lowercase. +## Filter + +The Filter sidebar panel loads a batch of files (independent of whatever's loaded in the +Rename panel) and narrows it down before you decide what to do with it elsewhere — copy +the matching paths out, or just get a sense of what's actually in a folder. Unlike SFV, +it adds no new Rust/Tauri code at all: it scans every loaded file with the exact same +local, filename-only matchers "Match" uses (`guess_episode`/`guess_movie`/ +`guess_release_info`) and turns the results into filter criteria instead of a rename +preview — no network calls, so scanning even a large batch is effectively instant. + +**Load Files…** loads and scans a batch in one step. Toggleable chips then appear for +every **extension** actually present (e.g. MKV/MP4/TXT) and every **kind** Aurora Curator +locally recognized (TV/Movies/Unmatched) — click a chip to exclude that group, click it +again to bring it back. A **filename contains** field does a live, case-insensitive +substring search, and a **season** field (shown only while the TV chip is enabled) narrows +to a single season number, automatically excluding anything that isn't a TV episode in +that season. The results list updates live with an "N of M files match" count, and +**Copy File Paths** writes every currently-matching file's full path, one per line, to the +clipboard. + ## Porting roadmap (not yet done) Roughly in priority order — each references the original FileBot source file it's @@ -542,11 +572,13 @@ ported from, for anyone picking this up: Align Up/Down reordering, naming-scheme Presets with `1`-`9` hotkeys) is built, and so are the Subtitles panel (search + download via OpenSubtitles, see "Subtitles" above), the Episodes panel (series search + season browse with a Copy list export, no - credentials of its own, see "Episodes" above), and the SFV panel (compute/verify + credentials of its own, see "Episodes" above), the SFV panel (compute/verify CRC32/MD5/SHA1 checksums, read/write `.sfv`/`.md5`/`.sha1` sidecar files, entirely - local with no credentials at all, see "SFV" above) — the first three of the other - five sidebar panels to become real screens. The remaining two (Filter/List) are - still stub screens. Align Up/Down is button-based + local with no credentials at all, see "SFV" above), and the Filter panel (extension/ + kind/filename/season narrowing over a locally-scanned batch, no new Rust/Tauri + surface at all, see "Filter" above) — the first four of the other five sidebar + panels to become real screens. The remaining one (List) is still a stub screen. + Align Up/Down is button-based (swaps the selected file with its neighbor), not the original's drag-and-drop-within-a-list interaction. 8. **Undo/history for renames** — done, as a flat JSON-backed undo stack rather than a diff --git a/src/App.tsx b/src/App.tsx index b3a4363..1fb3746 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import RenamePanel from "./components/RenamePanel"; import EpisodesPanel from "./components/EpisodesPanel"; import SubtitlesPanel from "./components/SubtitlesPanel"; import SfvPanel from "./components/SfvPanel"; +import FilterPanel from "./components/FilterPanel"; import PanelStub from "./components/PanelStub"; export default function App() { @@ -25,6 +26,8 @@ export default function App() { ) : selected === "sfv" ? ( + ) : selected === "filter" ? ( + ) : ( )} diff --git a/src/components/FilterPanel.css b/src/components/FilterPanel.css new file mode 100644 index 0000000..279a803 --- /dev/null +++ b/src/components/FilterPanel.css @@ -0,0 +1,136 @@ +.filter-panel { + height: 100%; + min-width: 0; + display: flex; + flex-direction: column; + gap: 14px; + padding: 18px; + overflow-y: auto; +} + +.filter-toolbar { + display: flex; + align-items: center; + gap: 14px; + background: var(--card); + border: 1px solid var(--surface-deep); + border-radius: 12px; + padding: 14px; +} + +.filter-loaded-count { + font-size: 12px; + color: var(--muted-text); +} + +.filter-criteria { + display: flex; + flex-direction: column; + gap: 10px; + background: var(--card); + border: 1px solid var(--surface-deep); + border-radius: 12px; + padding: 14px; +} + +.filter-criteria-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.filter-criteria-label { + flex: 0 0 130px; + font-size: 11.5px; + color: var(--muted-text); +} + +.filter-chip-row { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.filter-chip { + font-size: 11.5px; + font-weight: 600; + border-radius: 999px; + padding: 5px 12px; + background: var(--surface); + border: 1px solid var(--surface-deep); + color: var(--muted-text); + cursor: pointer; +} + +.filter-chip.selected { + background: rgba(82, 199, 219, 0.16); + border-color: var(--aurora-cyan); + color: var(--aurora-cyan); +} + +.filter-name-input { + flex: 1; + min-width: 160px; +} + +.filter-season-input { + width: 90px; +} + +.filter-results { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.filter-results-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 2px 2px 4px; +} + +.filter-match-count { + font-size: 13px; + font-weight: 600; +} + +.filter-row { + display: flex; + align-items: center; + gap: 12px; + background: var(--card); + border: 1px solid var(--surface-deep); + border-radius: 10px; + padding: 9px 12px; +} + +.filter-row-name { + flex: 1; + min-width: 0; + font-size: 12.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.filter-row-subtitle { + flex: 0 0 auto; + font-size: 11.5px; + color: var(--muted-text); +} + +.filter-empty-note { + color: var(--muted-text); + font-size: 12.5px; + padding: 8px 2px; +} + +.filter-status { + font-size: 12px; + color: var(--aurora-cyan); +} diff --git a/src/components/FilterPanel.tsx b/src/components/FilterPanel.tsx new file mode 100644 index 0000000..29248bf --- /dev/null +++ b/src/components/FilterPanel.tsx @@ -0,0 +1,303 @@ +import { useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +interface FileEntry { + path: string; + name: string; // filename without extension + ext: string; +} + +// Same local, filename-only guessers "Match" uses in the Rename panel (aurora-curator-core's +// `matching` module) - no network calls, so Filter can scan a freshly-loaded batch +// instantly and narrow it down before deciding what to do with it elsewhere. +interface EpisodeGuess { + seriesName: string; + season: number; + episode: number; +} + +interface MovieGuess { + title: string; + year: number | null; +} + +interface ReleaseInfo { + resolution: string | null; + source: string | null; + videoCodec: string | null; + audioCodec: string | null; + group: string | null; +} + +type MediaKind = "tv" | "movie" | "unmatched"; + +interface FileInfo { + kind: MediaKind; + seriesName?: string; + season?: number; + episode?: number; + movieName?: string; + year?: number | null; + release: ReleaseInfo | null; +} + +const KIND_LABELS: Record = { + tv: "TV", + movie: "Movies", + unmatched: "Unmatched", +}; + +function extLabel(ext: string): string { + return ext ? ext.toUpperCase() : "(none)"; +} + +function displayName(file: FileEntry): string { + return file.ext ? `${file.name}.${file.ext}` : file.name; +} + +function subtitleFor(info: FileInfo | undefined): string { + if (!info) return ""; + if (info.kind === "tv") { + const se = `S${String(info.season ?? 0).padStart(2, "0")}E${String(info.episode ?? 0).padStart(2, "0")}`; + return info.seriesName ? `${info.seriesName} · ${se}` : se; + } + if (info.kind === "movie") { + return info.year ? `${info.movieName} (${info.year})` : info.movieName ?? ""; + } + return "Unmatched"; +} + +export default function FilterPanel() { + const [files, setFiles] = useState([]); + const [info, setInfo] = useState>({}); + const [scanning, setScanning] = useState(false); + const [extensions, setExtensions] = useState>(new Set()); + const [kinds, setKinds] = useState>(new Set()); + const [nameContains, setNameContains] = useState(""); + const [season, setSeason] = useState(""); + const [status, setStatus] = useState(null); + + async function loadFiles() { + const picked = await invoke("pick_files"); + if (picked.length === 0) return; + setFiles(picked); + setInfo({}); + setNameContains(""); + setSeason(""); + setStatus(null); + await scan(picked); + } + + async function scan(targets: FileEntry[]) { + setScanning(true); + try { + const results = await Promise.all( + targets.map(async (file): Promise => { + const releasePromise = invoke("guess_release_info", { + filename: displayName(file), + }).catch(() => null); + + let fileInfo: FileInfo | null = null; + try { + const episode = await invoke("guess_episode", { filename: displayName(file) }); + if (episode) { + fileInfo = { + kind: "tv", + seriesName: episode.seriesName, + season: episode.season, + episode: episode.episode, + release: null, + }; + } + } catch { + // Fall through and try a movie guess instead. + } + if (!fileInfo) { + try { + const movie = await invoke("guess_movie", { filename: displayName(file) }); + if (movie) { + fileInfo = { kind: "movie", movieName: movie.title, year: movie.year, release: null }; + } + } catch { + // Neither guess worked. + } + } + if (!fileInfo) fileInfo = { kind: "unmatched", release: null }; + fileInfo.release = await releasePromise; + return [file.path, fileInfo] as const; + }) + ); + + const nextInfo: Record = {}; + const seenExt = new Set(); + const seenKinds = new Set(); + for (const [path, fileInfo] of results) { + nextInfo[path] = fileInfo; + seenKinds.add(fileInfo.kind); + } + for (const file of targets) seenExt.add(file.ext); + + setInfo(nextInfo); + setExtensions(seenExt); + setKinds(seenKinds); + setStatus(`Scanned ${targets.length} file${targets.length === 1 ? "" : "s"} locally (no network calls).`); + } finally { + setScanning(false); + } + } + + function toggleExtension(ext: string) { + setExtensions((prev) => { + const next = new Set(prev); + if (next.has(ext)) next.delete(ext); + else next.add(ext); + return next; + }); + } + + function toggleKind(kind: MediaKind) { + setKinds((prev) => { + const next = new Set(prev); + if (next.has(kind)) { + next.delete(kind); + if (kind === "tv") setSeason(""); + } else { + next.add(kind); + } + return next; + }); + } + + const allExtensions = Array.from(new Set(files.map((f) => f.ext))).sort(); + const allKinds = (["tv", "movie", "unmatched"] as MediaKind[]).filter((k) => + Object.values(info).some((i) => i.kind === k) + ); + + const seasonFilter = kinds.has("tv") && season.trim() !== "" ? Number(season) : null; + + const filtered = files.filter((file) => { + const fileInfo = info[file.path]; + if (!fileInfo) return false; + if (!extensions.has(file.ext)) return false; + if (!kinds.has(fileInfo.kind)) return false; + if (nameContains.trim() && !displayName(file).toLowerCase().includes(nameContains.trim().toLowerCase())) { + return false; + } + if (seasonFilter !== null && !(fileInfo.kind === "tv" && fileInfo.season === seasonFilter)) { + return false; + } + return true; + }); + + function copyPaths() { + if (filtered.length === 0) return; + navigator.clipboard + .writeText(filtered.map((f) => f.path).join("\n")) + .then(() => setStatus(`Copied ${filtered.length} file path${filtered.length === 1 ? "" : "s"} to the clipboard.`)) + .catch(() => setStatus("Couldn't copy to the clipboard.")); + } + + return ( +
+
+ + + {files.length > 0 ? `${files.length} file${files.length === 1 ? "" : "s"} loaded` : "No files loaded"} + +
+ + {files.length > 0 && ( +
+ {allExtensions.length > 0 && ( +
+ Extension +
+ {allExtensions.map((ext) => ( + + ))} +
+
+ )} + + {allKinds.length > 0 && ( +
+ Kind +
+ {allKinds.map((kind) => ( + + ))} +
+
+ )} + +
+ Filename contains + setNameContains(e.target.value)} + /> +
+ + {kinds.has("tv") && ( +
+ Season + setSeason(e.target.value)} + /> +
+ )} +
+ )} + +
+ {files.length === 0 &&
Load files to filter them by extension, kind, name, or season.
} + + {files.length > 0 && ( +
+ + {scanning ? "Scanning…" : `${filtered.length} of ${files.length} files match`} + + +
+ )} + + {files.length > 0 && !scanning && filtered.length === 0 && ( +
No files match the current filters.
+ )} + + {filtered.map((file) => ( +
+ {displayName(file)} + {subtitleFor(info[file.path])} +
+ ))} +
+ + {status &&
{status}
} +
+ ); +} diff --git a/src/main.tsx b/src/main.tsx index 4cb895a..b2a57e2 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -9,6 +9,7 @@ import "./components/RenamePanel.css"; import "./components/EpisodesPanel.css"; import "./components/SubtitlesPanel.css"; import "./components/SfvPanel.css"; +import "./components/FilterPanel.css"; import "./components/PanelStub.css"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(