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
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
+3
View File
@@ -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() {
<SubtitlesPanel />
) : selected === "sfv" ? (
<SfvPanel />
) : selected === "filter" ? (
<FilterPanel />
) : (
<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/SubtitlesPanel.css";
import "./components/SfvPanel.css";
import "./components/FilterPanel.css";
import "./components/PanelStub.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(