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:
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user