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).
This commit is contained in:
Renamr Scaffold
2026-08-29 17:01:15 +00:00
parent 316dbfc1ff
commit bfcec9135a
5 changed files with 481 additions and 6 deletions
+38 -6
View File
@@ -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 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 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 `.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 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 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 checksum file and verify the files it lists against what's
actually on disk (OK/mismatch/missing per entry) — no settings actually on disk (OK/mismatch/missing per entry) — no settings
modal at all, since the feature needs no credentials or network 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/gen_icons.py Regenerates src-tauri/icons/ from the real logo mark
(`scripts/logo/icon-source.png`, cropped from the supplied (`scripts/logo/icon-source.png`, cropped from the supplied
lockup) — replace that source image to change the look. 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 referenced file isn't there). Checksum comparison is case-insensitive, since some tools
write hex digests in uppercase and others in lowercase. 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) ## Porting roadmap (not yet done)
Roughly in priority order — each references the original FileBot source file it's 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 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), are the Subtitles panel (search + download via OpenSubtitles, see "Subtitles" above),
the Episodes panel (series search + season browse with a Copy list export, no 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 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 local with no credentials at all, see "SFV" above), and the Filter panel (extension/
five sidebar panels to become real screens. The remaining two (Filter/List) are kind/filename/season narrowing over a locally-scanned batch, no new Rust/Tauri
still stub screens. Align Up/Down is button-based 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 (swaps the selected file with its neighbor), not the original's
drag-and-drop-within-a-list interaction. drag-and-drop-within-a-list interaction.
8. **Undo/history for renames** — done, as a flat JSON-backed undo stack rather than a 8. **Undo/history for renames** — done, as a flat JSON-backed undo stack rather than a
+3
View File
@@ -5,6 +5,7 @@ import RenamePanel from "./components/RenamePanel";
import EpisodesPanel from "./components/EpisodesPanel"; import EpisodesPanel from "./components/EpisodesPanel";
import SubtitlesPanel from "./components/SubtitlesPanel"; import SubtitlesPanel from "./components/SubtitlesPanel";
import SfvPanel from "./components/SfvPanel"; import SfvPanel from "./components/SfvPanel";
import FilterPanel from "./components/FilterPanel";
import PanelStub from "./components/PanelStub"; import PanelStub from "./components/PanelStub";
export default function App() { export default function App() {
@@ -25,6 +26,8 @@ export default function App() {
<SubtitlesPanel /> <SubtitlesPanel />
) : selected === "sfv" ? ( ) : selected === "sfv" ? (
<SfvPanel /> <SfvPanel />
) : selected === "filter" ? (
<FilterPanel />
) : ( ) : (
<PanelStub name={current.name} /> <PanelStub name={current.name} />
)} )}
+136
View File
@@ -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);
}
+303
View File
@@ -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<MediaKind, string> = {
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<FileEntry[]>([]);
const [info, setInfo] = useState<Record<string, FileInfo>>({});
const [scanning, setScanning] = useState(false);
const [extensions, setExtensions] = useState<Set<string>>(new Set());
const [kinds, setKinds] = useState<Set<MediaKind>>(new Set());
const [nameContains, setNameContains] = useState("");
const [season, setSeason] = useState("");
const [status, setStatus] = useState<string | null>(null);
async function loadFiles() {
const picked = await invoke<FileEntry[]>("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<readonly [string, FileInfo]> => {
const releasePromise = invoke<ReleaseInfo | null>("guess_release_info", {
filename: displayName(file),
}).catch(() => null);
let fileInfo: FileInfo | null = null;
try {
const episode = await invoke<EpisodeGuess | null>("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<MovieGuess | null>("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<string, FileInfo> = {};
const seenExt = new Set<string>();
const seenKinds = new Set<MediaKind>();
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 (
<div className="filter-panel">
<div className="filter-toolbar">
<button className="action-button" onClick={loadFiles}>
Load Files…
</button>
<span className="filter-loaded-count">
{files.length > 0 ? `${files.length} file${files.length === 1 ? "" : "s"} loaded` : "No files loaded"}
</span>
</div>
{files.length > 0 && (
<div className="filter-criteria">
{allExtensions.length > 0 && (
<div className="filter-criteria-row">
<span className="filter-criteria-label">Extension</span>
<div className="filter-chip-row" role="group" aria-label="Filter by extension">
{allExtensions.map((ext) => (
<button
key={ext || "(none)"}
className={`filter-chip${extensions.has(ext) ? " selected" : ""}`}
onClick={() => toggleExtension(ext)}
>
{extLabel(ext)}
</button>
))}
</div>
</div>
)}
{allKinds.length > 0 && (
<div className="filter-criteria-row">
<span className="filter-criteria-label">Kind</span>
<div className="filter-chip-row" role="group" aria-label="Filter by media kind">
{allKinds.map((kind) => (
<button
key={kind}
className={`filter-chip${kinds.has(kind) ? " selected" : ""}`}
onClick={() => toggleKind(kind)}
>
{KIND_LABELS[kind]}
</button>
))}
</div>
</div>
)}
<div className="filter-criteria-row">
<span className="filter-criteria-label">Filename contains</span>
<input
className="modal-text-input filter-name-input"
type="text"
placeholder="e.g. director's cut"
value={nameContains}
onChange={(e) => setNameContains(e.target.value)}
/>
</div>
{kinds.has("tv") && (
<div className="filter-criteria-row">
<span className="filter-criteria-label">Season</span>
<input
className="modal-text-input filter-season-input"
type="number"
min={1}
placeholder="any"
value={season}
onChange={(e) => setSeason(e.target.value)}
/>
</div>
)}
</div>
)}
<div className="filter-results">
{files.length === 0 && <div className="filter-empty-note">Load files to filter them by extension, kind, name, or season.</div>}
{files.length > 0 && (
<div className="filter-results-header">
<span className="filter-match-count">
{scanning ? "Scanning…" : `${filtered.length} of ${files.length} files match`}
</span>
<button className="action-button" onClick={copyPaths} disabled={filtered.length === 0}>
Copy File Paths
</button>
</div>
)}
{files.length > 0 && !scanning && filtered.length === 0 && (
<div className="filter-empty-note">No files match the current filters.</div>
)}
{filtered.map((file) => (
<div className="filter-row" key={file.path}>
<span className="filter-row-name">{displayName(file)}</span>
<span className="filter-row-subtitle">{subtitleFor(info[file.path])}</span>
</div>
))}
</div>
{status && <div className="filter-status">{status}</div>}
</div>
);
}
+1
View File
@@ -9,6 +9,7 @@ import "./components/RenamePanel.css";
import "./components/EpisodesPanel.css"; import "./components/EpisodesPanel.css";
import "./components/SubtitlesPanel.css"; import "./components/SubtitlesPanel.css";
import "./components/SfvPanel.css"; import "./components/SfvPanel.css";
import "./components/FilterPanel.css";
import "./components/PanelStub.css"; import "./components/PanelStub.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(