Compare commits
13
Commits
v0.1.0-linux
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce115978c5 | ||
|
|
56f57545ac | ||
|
|
8118a53e35 | ||
|
|
03390b43f1 | ||
|
|
635d57f55b | ||
|
|
8cabe7c9ff | ||
|
|
6d98750642 | ||
|
|
25dedc1c36 | ||
|
|
9b620bb7d2 | ||
|
|
b409d67510 | ||
|
|
ecd98d6c57 | ||
|
|
e219e8f2a8 | ||
|
|
23c9ca609f |
@@ -35,7 +35,9 @@ file's own container header for its real resolution, frame rate, duration, and
|
||||
codecs — no ffmpeg/MediaInfo dependency required — filling in `{resolution}`/
|
||||
`{video codec}`/`{audio codec}` whenever the filename itself didn't already carry
|
||||
that tag, plus `{width}`/`{height}`/`{fps}`/`{duration}`/`{file size}`, which have no
|
||||
filename-tag equivalent at all; see "Real media-file probing" below. The Align
|
||||
filename-tag equivalent at all; see "Real media-file probing" below. A separate **Hash
|
||||
Files** button computes real `{crc32}`/`{md5}`/`{sha1}` checksums on demand (deliberately
|
||||
not automatic, since it reads a file's entire contents — see "Hash Files" below). The Align
|
||||
Up/Down buttons reorder the (shared) file list — the two
|
||||
panes always stay positionally paired — and up to 9 naming schemes can be saved as
|
||||
presets, recalled with a click or a `1`-`9` keypress (persisted locally, same pattern as
|
||||
@@ -64,7 +66,14 @@ file via a native Save dialog — this closes out the full original panel set (R
|
||||
Episodes/Subtitles/SFV/Filter/List); see "List" below. Real media-file probing (real
|
||||
resolution, frame rate, duration, and codecs read straight from a video file's own
|
||||
container header, not guessed from its filename) is wired into the Rename panel's
|
||||
"Match" step — see "Real media-file probing" below. See "Porting roadmap" below.
|
||||
"Match" step, and now the Filter and List panels' own local scans too — Filter gained
|
||||
Resolution/Video codec filter chips from it, and List's expressions can reference the
|
||||
same probed fields — see "Real media-file probing" below. The naming-expression engine's Rhai
|
||||
scripting layer also gained a batch of FileBot-style string helpers — `.pad()`,
|
||||
`.sort_name()`/`.sort_initial()`, `.upper_initial()`/`.lower_initial()`, `.acronym()`,
|
||||
`.validate_file_name()`, `.space()`/`.space_with()`, `.colon()`/`.colon_with()` — callable
|
||||
inside any `{ }` naming-scheme expression, e.g. `{n.sort_name()}/S{s.pad(2)}E{e.pad(2)}`;
|
||||
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
|
||||
@@ -73,7 +82,12 @@ Plain, human-readable, multi-word field names like `{show name}` work directly (
|
||||
Rhai knowledge needed), FileBot's own short field names (`{n}`, `{s00e00}`, `{t}`)
|
||||
still work side by side with them, and real Rhai script is available for anyone who
|
||||
wants it (e.g. `{show name.to_upper()}`). See "Naming scheme syntax" below for how
|
||||
that's implemented.
|
||||
that's implemented. Every field name shown in the editor is also a clickable button —
|
||||
click one to insert it at the cursor instead of typing it out by hand. Whatever's
|
||||
currently in the editor is saved to disk as you type (debounced) and restored the next
|
||||
time the app opens, so a custom scheme survives a restart instead of resetting to the
|
||||
sample expression above; the 1-9 preset slots (a separate, explicitly-named save) are
|
||||
still there alongside this for switching between several schemes on purpose.
|
||||
|
||||
## Why a rewrite, and why this stack
|
||||
|
||||
@@ -228,14 +242,18 @@ src-tauri/ Tauri app shell: exposes aurora-curator-core as commands
|
||||
`browse_episodes` command backing the Episodes panel — series
|
||||
search + one season's episode list, via a `build_tv_client` helper
|
||||
now shared with `metadata.rs`'s `fetch_metadata`, so both read the
|
||||
same stored TV-source setting and credential), `sfv.rs` (three
|
||||
commands backing the SFV panel — `compute_checksums` over a batch
|
||||
of loaded files with per-file error isolation, `save_checksum_file`
|
||||
via a native "Save File" dialog, and `verify_checksum_file`, which
|
||||
reads and parses a picked `.sfv`/`.md5`/`.sha1` file and resolves
|
||||
each entry's bare filename relative to that file's own parent
|
||||
directory — all wrapping `aurora-curator-core::sfv`, which does the
|
||||
actual algorithm/format work),
|
||||
same stored TV-source setting and credential), `sfv.rs` (four
|
||||
commands backing the SFV panel and the Rename panel's Hash Files
|
||||
button — `compute_checksums` over a batch of loaded files with
|
||||
per-file error isolation, `save_checksum_file` via a native "Save
|
||||
File" dialog, `verify_checksum_file`, which reads and parses a
|
||||
picked `.sfv`/`.md5`/`.sha1` file and resolves each entry's bare
|
||||
filename relative to that file's own parent directory, and
|
||||
`compute_naming_hashes` — all four wrapping
|
||||
`aurora-curator-core::sfv`, which does the actual algorithm/format
|
||||
work; `compute_naming_hashes` calls the crate's
|
||||
`compute_all_checksums` rather than `compute_checksum` three times,
|
||||
streaming each file once instead of three),
|
||||
`history.rs` (rename history/undo
|
||||
log), `presets.rs` (up to 9 saved naming schemes) — all three
|
||||
stored as JSON in the OS app-config directory — `list.rs` (a
|
||||
@@ -303,9 +321,25 @@ scripts/gen_icons.py Regenerates src-tauri/icons/ from the real logo mark
|
||||
## Building
|
||||
|
||||
Prerequisites: Rust (stable) + Cargo, Node.js 18+, and Tauri's platform prerequisites
|
||||
(see https://v2.tauri.app/start/prerequisites/ — on Linux this means
|
||||
`libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev` and a C
|
||||
toolchain; macOS/Windows need Xcode / the VS Build Tools + WebView2 respectively).
|
||||
(see https://v2.tauri.app/start/prerequisites/ for the full, up-to-date list).
|
||||
|
||||
**Linux:** `libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev
|
||||
librsvg2-dev` plus a C toolchain (`build-essential` on Debian/Ubuntu). Bundling the
|
||||
installers additionally needs `patchelf` (for the `.deb`/`.rpm`) — `npm run tauri build`
|
||||
will also download a few small `linuxdeploy` helper tools from GitHub the first time it
|
||||
builds an `.AppImage`, so a network connection is needed for that step.
|
||||
|
||||
**macOS:** Xcode (or at least the Xcode Command Line Tools — `xcode-select --install`).
|
||||
No other system packages are needed.
|
||||
|
||||
**Windows:** Rust via [rustup](https://rustup.rs/), using the default MSVC toolchain
|
||||
(not the GNU one). [Visual Studio Build Tools](https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio)
|
||||
(or full Visual Studio) with the **"Desktop development with C++"** workload, which
|
||||
provides the linker Rust's MSVC toolchain needs. The **WebView2 Runtime** — this is
|
||||
already preinstalled on any up-to-date Windows 10/11 machine, so you usually don't need
|
||||
to do anything; if you hit a WebView2-related error, grab the Evergreen bootstrapper from
|
||||
Microsoft. You do **not** need to separately install NSIS or WiX — Tauri's bundler
|
||||
downloads NSIS automatically the first time it builds the `.exe` installer.
|
||||
|
||||
```sh
|
||||
# Rust core (no UI deps needed):
|
||||
@@ -319,14 +353,20 @@ npm run tauri dev
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
Verified in the environment this was built in: `cargo test --workspace` (all 160
|
||||
passing, including the TMDB, TVmaze, OMDb, and OpenSubtitles client tests — those run
|
||||
against a local mock HTTP server via `mockito`, no live network or API key needed to
|
||||
build/test), `cargo
|
||||
check -p aurora-curator` (compiles clean against real Tauri + GTK/WebKit system
|
||||
libraries), and `npm run build` (TypeScript type-checks, Vite bundles). The actual GUI
|
||||
window has **not** been visually verified anywhere yet — that sandbox has no display.
|
||||
Run `npm run tauri dev` on your machine to see and click through it for the first time.
|
||||
`npm run tauri build` produces a native installer for whichever OS you run it on — there
|
||||
is no cross-compilation; build on Windows to get a `.exe`/`.msi`, on Linux for
|
||||
`.deb`/`.rpm`/`.AppImage`, on macOS for a `.app`/`.dmg`.
|
||||
|
||||
Verified so far: `cargo test --workspace` (199 tests passing, including the TMDB,
|
||||
TVmaze, OMDb, and OpenSubtitles client tests — those run against a local mock HTTP
|
||||
server via `mockito`, no live network or API key needed to build/test), `cargo check -p
|
||||
aurora-curator` (compiles clean against real Tauri + GTK/WebKit system libraries), and
|
||||
`npm run build` (TypeScript type-checks, Vite bundles). The production build
|
||||
(`npm run tauri build`) has been run end-to-end and the resulting app **launches and
|
||||
runs correctly outside of dev mode** on both macOS (`.app`, confirmed by hand) and Linux
|
||||
(`.deb`/`.rpm`/`.AppImage`, built and smoke-tested in a real Ubuntu environment). Windows
|
||||
is the one platform not yet built/verified — if you hit anything building there, please
|
||||
open an issue.
|
||||
|
||||
### Setting up metadata sources (for "Fetch Data")
|
||||
|
||||
@@ -495,21 +535,29 @@ write hex digests in uppercase and others in lowercase.
|
||||
|
||||
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.
|
||||
the matching paths out, or just get a sense of what's actually in a folder. 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. It also runs the same batched `probe_media_files` real container-header
|
||||
probe "Match" and List do (see "Real media-file probing" below), so a file's resolution/
|
||||
video codec can come from real probing even when nothing in its filename says so.
|
||||
|
||||
**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
|
||||
again to bring it back. **Resolution** and **Video codec** chip rows appear the same way
|
||||
whenever at least one loaded file actually has a value for either (a filename tag wins
|
||||
when present, otherwise a real probed value fills in — same override rule as everywhere
|
||||
else this delivery touches; files with neither fall into their own "Unknown" chip rather
|
||||
than being silently excluded). 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.
|
||||
that season. Each result row's subtitle also shows a resolved resolution when one exists
|
||||
(e.g. `Unmatched · 480p` for a file Match couldn't identify as TV or a movie but still got
|
||||
a real probed resolution for). 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.
|
||||
|
||||
## List
|
||||
|
||||
@@ -525,26 +573,31 @@ native Save dialog and writes whatever text the frontend hands it to a `.txt` fi
|
||||
everything else (matching, and rendering each file's line) reuses commands that already
|
||||
existed.
|
||||
|
||||
**Load Files…** loads a batch and scans it locally, the same way Filter does. The
|
||||
**Format** field holds the expression rendered against each file — the exact same `{ }`
|
||||
syntax the naming scheme uses, run through the same `render_expression` command the
|
||||
Rename panel's live preview calls. `{name}`/`{ext}` always work (they're the file's own
|
||||
name/extension); `{show name}`/`{season number}`/`{episode number}`, `{movie name}`/
|
||||
`{year}`, and the release-tag fields (`{resolution}`/`{source}`/`{video codec}`/
|
||||
`{audio codec}`/`{release group}`) fill in once the local scan recognizes a file — there's
|
||||
no Fetch Data step here, List stays local-only like Filter. A file whose rendered line
|
||||
comes out empty (e.g. an expression that only references a field this particular file
|
||||
never matched) shows an "(empty)" placeholder rather than blocking the rest of the list,
|
||||
and the results header tracks "N of M lines rendered" live as the expression changes.
|
||||
**Copy List** copies every line, in file order, newline-joined, to the clipboard; **Export
|
||||
to File…** opens a native Save dialog (defaulted to `list.txt`) and writes the same text
|
||||
to disk.
|
||||
**Load Files…** loads a batch and scans it locally, the same way Filter does — including
|
||||
the same batched real container-header probe (see "Real media-file probing" below), so
|
||||
List's expressions get real probed values too, not just filename-tag ones. The **Format**
|
||||
field holds the expression rendered against each file — the exact same `{ }` syntax the
|
||||
naming scheme uses, run through the same `render_expression` command the Rename panel's
|
||||
live preview calls. `{name}`/`{ext}` always work (they're the file's own name/extension);
|
||||
`{show name}`/`{season number}`/`{episode number}`, `{movie name}`/`{year}`, and the
|
||||
release-tag fields (`{resolution}`/`{source}`/`{video codec}`/`{audio codec}`/`{release
|
||||
group}`) fill in once the local scan recognizes a file; `{width}`/`{height}`/`{fps}`/
|
||||
`{duration}`/`{file size}` come straight from real probing (no filename-tag equivalent
|
||||
exists), and a probed `{resolution}`/`{video codec}`/`{audio codec}` fills in whenever the
|
||||
filename didn't already carry that tag — there's no Fetch Data step here, List stays
|
||||
local-only like Filter. A file whose rendered line comes out empty (e.g. an expression
|
||||
that only references a field this particular file never matched) shows an "(empty)"
|
||||
placeholder rather than blocking the rest of the list, and the results header tracks
|
||||
"N of M lines rendered" live as the expression changes. **Copy List** copies every line, in
|
||||
file order, newline-joined, to the clipboard; **Export to File…** opens a native Save
|
||||
dialog (defaulted to `list.txt`) and writes the same text to disk.
|
||||
|
||||
## Real media-file probing
|
||||
|
||||
Unlike every panel above, this isn't a new sidebar screen — it's a naming-scheme
|
||||
binding source wired into the Rename panel's existing "Match" step, closing (part of)
|
||||
the gap the porting roadmap has called out since the beginning: FileBot's
|
||||
binding source wired into the Rename panel's "Match" step, the Filter panel's scan, and
|
||||
the List panel's scan alike, closing (part of) the gap the porting roadmap has called out
|
||||
since the beginning: FileBot's
|
||||
`MediaBindingBean` doesn't just parse tags out of a release name, it also reads a
|
||||
video file's own container header for its real resolution, frame rate, duration, and
|
||||
codecs — a naming scheme can reference those even when the filename itself doesn't
|
||||
@@ -557,19 +610,42 @@ values — probing is always a best-effort enrichment layered on top of filename
|
||||
matching, never a hard requirement, so an unsupported or corrupt file can't break
|
||||
Match.
|
||||
|
||||
Clicking **Match** now also probes every loaded file's real container header
|
||||
(`probe_media_files`, one batched Tauri round trip off the main thread) alongside the
|
||||
existing filename-based guessing. The result feeds the same bindings the release-tag
|
||||
parser already populates — `{resolution}`/`{vf}`, `{video codec}`/`{vc}`,
|
||||
`{audio codec}`/`{ac}` — but only when the filename itself didn't already carry that
|
||||
tag; an explicit release-group tag always wins over a probed value, since a release
|
||||
group's own tag is usually deliberate. Five bindings have no filename-tag equivalent
|
||||
at all, so they're only ever available this way: `{width}`, `{height}`, `{fps}`,
|
||||
`{duration}` (`h:mm:ss`, or `m:ss` under an hour), and `{file size}` (human-readable,
|
||||
e.g. `1.4 GB`) — all formatted once on the Rust side so the UI never reimplements that
|
||||
logic. The Match status line reports how many loaded files were actually probed
|
||||
(`"N probed from file"`), and the naming-scheme editor's field-reference list has a
|
||||
new "Real file probing" group listing all five.
|
||||
Clicking **Match** (Rename), or loading a batch into **Filter** or **List**, now also
|
||||
probes every loaded file's real container header (`probe_media_files`, one batched Tauri
|
||||
round trip off the main thread) alongside the existing filename-based guessing — all
|
||||
three panels call the exact same command the exact same way. The result feeds the same
|
||||
bindings the release-tag parser already populates — `{resolution}`/`{vf}`, `{video
|
||||
codec}`/`{vc}`, `{audio codec}`/`{ac}` — but only when the filename itself didn't already
|
||||
carry that tag; an explicit release-group tag always wins over a probed value, since a
|
||||
release group's own tag is usually deliberate. Five bindings have no filename-tag
|
||||
equivalent at all, so they're only ever available this way: `{width}`, `{height}`,
|
||||
`{fps}`, `{duration}` (`h:mm:ss`, or `m:ss` under an hour), and `{file size}`
|
||||
(human-readable, e.g. `1.4 GB`) — all formatted once on the Rust side so the UI never
|
||||
reimplements that logic. Rename's Match status line, List's scan status, and Filter's
|
||||
scan status all report how many loaded files were actually probed (`"N probed from
|
||||
file"`), the naming-scheme editor's field-reference list has a "Real file probing" group
|
||||
listing all five width/height/fps/duration/file-size bindings, and the Filter panel adds
|
||||
its own **Resolution**/**Video codec** chip rows (populated from the same merged
|
||||
filename-tag-or-probed value) so a batch can be narrowed by either, not just by
|
||||
extension/kind/name/season.
|
||||
|
||||
## Hash Files
|
||||
|
||||
A naming-scheme binding source, same idea as real media-file probing above, but for
|
||||
`{crc32}`/`{md5}`/`{sha1}` — reuses the SFV panel's own hasher
|
||||
(`aurora_curator_core::sfv`) rather than a separate implementation. Unlike Match's other
|
||||
local steps (filename parsing, container-header probing), this is deliberately **not**
|
||||
wired into Match automatically: a checksum needs the file's *entire* contents streamed
|
||||
through, not just a small header or filename, so hashing a batch of large video files
|
||||
can take real time — running it unasked on every Match click would make Match
|
||||
unpredictably slow for schemes that don't even use a hash field. Instead, the Rename
|
||||
panel's New Names list has its own **Hash Files** button next to Fetch Data; clicking it
|
||||
computes all three checksums for every loaded file in one pass per file (not three
|
||||
separate passes) and fills in `{crc32}`/`{md5}`/`{sha1}` for whichever files succeeded,
|
||||
reporting how many hashed vs. failed (a file that's moved or deleted between loading and
|
||||
hashing, a permissions problem, etc. — kept per-file, so one bad file doesn't block the
|
||||
rest of the batch). The naming-scheme editor's field-reference list has a "File hashing"
|
||||
group with all three as clickable buttons.
|
||||
|
||||
## Porting roadmap (not yet done)
|
||||
|
||||
@@ -586,16 +662,30 @@ ported from, for anyone picking this up:
|
||||
actually reading the file — real resolution/video codec/audio codec fill in
|
||||
whenever the filename didn't already carry that tag, plus `{width}`/`{height}`/
|
||||
`{fps}`/`{duration}`/`{file size}`, which have no filename-tag equivalent at all —
|
||||
for the two container families it supports (MP4/M4V/MOV and MKV/WebM). Still
|
||||
missing: probing for other containers (.avi, .wmv, ...), audio-only metadata
|
||||
(bitrate, sample rate, channel layout), file hashing (`{crc32}` reuses the SFV
|
||||
panel's own hasher today but isn't wired into naming-scheme bindings yet), and
|
||||
wiring probed bindings into the List/Filter panels (currently Rename-panel-only).
|
||||
for the two container families it supports (MP4/M4V/MOV and MKV/WebM). Probed
|
||||
bindings are now wired into all three panels that do local matching — Rename's
|
||||
Match step, and the Filter and List panels' own scans (Filter also gained
|
||||
Resolution/Video codec filter chips from it). File hashing is done too —
|
||||
`{crc32}`/`{md5}`/`{sha1}` reuse the SFV panel's own hasher (all three computed in a
|
||||
single pass over the file, rather than reading it three times), wired to the Rename
|
||||
panel's own "Hash Files" button rather than Match, since a full-file read is much
|
||||
slower than everything else Match does and shouldn't run unasked for every batch
|
||||
(see "Hash Files" below). Still missing: probing for other containers (.avi, .wmv,
|
||||
...) and audio-only metadata (bitrate, sample rate, channel layout).
|
||||
2. **String/expression helper methods** — `net.filebot.format.ExpressionFormatMethods`
|
||||
(636 lines): FileBot-specific Groovy string extensions like `.pad()`, `.sortName()`.
|
||||
Only a small subset (`any`, `all_of`, `concat`) is ported so far
|
||||
(`crates/aurora-curator-core/src/format/functions.rs`); Rhai's built-ins cover some of the
|
||||
rest already.
|
||||
Ported so far (`crates/aurora-curator-core/src/format/functions.rs`): `any`, `all_of`,
|
||||
`concat`, plus a newer batch of naming/scripting helpers — `pad`/`pad_with`
|
||||
(left-pad, non-mutating; note this is a deliberately different name collision-avoidance
|
||||
from Rhai's own built-in `pad(length, char)`, which right-pads and mutates in place —
|
||||
not a fit for FileBot's zero-padded season/episode numbers), `sort_name`/`sort_initial`
|
||||
(moves a leading "The"/"A"/"An" article to the end, e.g. `{n.sort_name()}` turns
|
||||
`"The Office"` into `"Office, The"`), `upper_initial`/`lower_initial`, `acronym`,
|
||||
`validate_file_name` (strips characters illegal in filenames, collapses whitespace,
|
||||
trims trailing dots), and `space`/`space_with`/`colon`/`colon_with` (separator/colon
|
||||
normalization). Rhai's built-ins cover some of the rest already. Still not ported:
|
||||
FileBot's CSV/file-reading and shell-quoting helpers (`csv`, `read_lines`,
|
||||
`quote_bash`, `quote_powershell`, etc.).
|
||||
3. **Matching/similarity engine** — `net.filebot.similarity.*`. Local filename-based
|
||||
season/episode detection is done (`crates/aurora-curator-core/src/matching`, ported from the
|
||||
pattern-matching half of `SeasonEpisodeMatcher.java`, adapted to Rust's `regex` crate
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
//! Global helper functions available inside every `{ }` expression, e.g. `{any([x, y])}`.
|
||||
//!
|
||||
//! Starter subset ported from FileBot's `ExpressionFormatFunctions.java`. Rhai already
|
||||
//! provides `to_upper`, `to_lower`, `trim`, `sub_string`, `len`, `contains`, `pad` and
|
||||
//! friends as built-in string methods, so this module only adds the FileBot-specific
|
||||
//! helpers that don't already have a Rhai equivalent. More helpers (csv/read_lines/
|
||||
//! quote_bash/quote_powershell, etc.) are tracked in the porting backlog.
|
||||
//! provides `to_upper`, `to_lower`, `trim`, `sub_string`, `len`, `contains` and friends as
|
||||
//! built-in string methods, so this module only adds the FileBot-specific helpers that
|
||||
//! don't already have a Rhai equivalent. More helpers (csv/read_lines/quote_bash/
|
||||
//! quote_powershell, etc.) are tracked in the porting backlog.
|
||||
//!
|
||||
//! **`pad` note**: Rhai *does* register a built-in `pad(length, char)`, but it mutates
|
||||
//! the string in place and pads on the RIGHT (`"5".pad(3, "0")` -> `"500"`). FileBot's
|
||||
//! naming-scheme `.pad(n)` zero-pads a short number on the LEFT and returns a new value
|
||||
//! (`"5".pad(2)` -> `"05"`, for e.g. `{s.pad(2)}` season numbers) - different enough that
|
||||
//! it's re-implemented here under the same name; Rhai resolves by argument
|
||||
//! count/type, so this doesn't collide with the built-in as long as call sites use the
|
||||
//! arities registered below.
|
||||
|
||||
use rhai::{Array, Dynamic, Engine};
|
||||
|
||||
@@ -44,9 +52,270 @@ fn concat(parts: Array) -> String {
|
||||
.join("")
|
||||
}
|
||||
|
||||
/// Left-pads `value`'s string form with `'0'` up to `width` characters. A value already
|
||||
/// at or past `width` is returned unchanged (never truncated).
|
||||
fn pad(value: Dynamic, width: i64) -> String {
|
||||
pad_with(value, width, "0".to_string())
|
||||
}
|
||||
|
||||
/// Same as [`pad`], but with a caller-chosen pad character (only its first `char` is
|
||||
/// used; an empty string falls back to `'0'`).
|
||||
fn pad_with(value: Dynamic, width: i64, pad_char: String) -> String {
|
||||
let s = value.to_string();
|
||||
let pad_char = pad_char.chars().next().unwrap_or('0');
|
||||
let width = width.max(0) as usize;
|
||||
let len = s.chars().count();
|
||||
if len >= width {
|
||||
return s;
|
||||
}
|
||||
let mut result: String = std::iter::repeat(pad_char).take(width - len).collect();
|
||||
result.push_str(&s);
|
||||
result
|
||||
}
|
||||
|
||||
/// The three English articles FileBot's `sortName`/`sortInitial` recognize and move to
|
||||
/// the end for alphabetical sorting purposes.
|
||||
const LEADING_ARTICLES: [&str; 3] = ["The", "An", "A"];
|
||||
|
||||
/// If `s` starts with one of [`LEADING_ARTICLES`] (case-insensitively) followed by a
|
||||
/// space, returns `(article-as-written-in-s, rest-of-string)`. `"An"` is checked before
|
||||
/// `"A"` so `"An Officer"` doesn't wrongly match just the leading `"A"`.
|
||||
fn split_leading_article(s: &str) -> Option<(&str, &str)> {
|
||||
for article in LEADING_ARTICLES {
|
||||
if s.len() > article.len() {
|
||||
let (head, tail) = s.split_at(article.len());
|
||||
if head.eq_ignore_ascii_case(article) && tail.starts_with(' ') {
|
||||
return Some((head, &tail[1..]));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Moves a leading "The"/"A"/"An" to the end as `", <Article>"`, e.g. `"The Office"` ->
|
||||
/// `"Office, The"`. A title with no leading article is returned unchanged.
|
||||
fn sort_name(value: Dynamic) -> String {
|
||||
let s = value.to_string();
|
||||
match split_leading_article(&s) {
|
||||
Some((article, rest)) => format!("{rest}, {article}"),
|
||||
None => s,
|
||||
}
|
||||
}
|
||||
|
||||
/// The single uppercase character `sort_name` would alphabetize this title under, i.e.
|
||||
/// the first letter of the string with any leading article stripped first.
|
||||
fn sort_initial(value: Dynamic) -> String {
|
||||
let s = value.to_string();
|
||||
let base = split_leading_article(&s).map(|(_, rest)| rest).unwrap_or(&s);
|
||||
base.chars().next().map(|c| c.to_uppercase().to_string()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Uppercases just the first character; the rest of the string is untouched.
|
||||
fn upper_initial(value: Dynamic) -> String {
|
||||
let s = value.to_string();
|
||||
let mut chars = s.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
None => s,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowercases just the first character; the rest of the string is untouched.
|
||||
fn lower_initial(value: Dynamic) -> String {
|
||||
let s = value.to_string();
|
||||
let mut chars = s.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
|
||||
None => s,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an acronym from the first alphanumeric character of each whitespace-separated
|
||||
/// word, uppercased, e.g. `"Attack on Titan"` -> `"AOT"`. A simplified reading of
|
||||
/// FileBot's original (which preserves the case of minor words in some cases) - Aurora
|
||||
/// Curator always uppercases the whole acronym, which is the more common naming-scheme
|
||||
/// use case and avoids depending on an English stop-word list.
|
||||
fn acronym(value: Dynamic) -> String {
|
||||
value
|
||||
.to_string()
|
||||
.split_whitespace()
|
||||
.filter_map(|word| word.chars().find(|c| c.is_alphanumeric()))
|
||||
.map(|c| c.to_ascii_uppercase())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// True for a character illegal (or reserved) in a filename on at least one of
|
||||
/// Windows/macOS/Linux: the Windows-reserved set plus the NUL/control range and the
|
||||
/// path separators. Doesn't reject `/` on its own here - callers that need a full *path*
|
||||
/// rather than a single filename component should not route through this function.
|
||||
fn is_illegal_filename_char(c: char) -> bool {
|
||||
matches!(c, '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || (c as u32) < 0x20
|
||||
}
|
||||
|
||||
/// Makes `value`'s string form safe to use as a filename: illegal characters (see
|
||||
/// [`is_illegal_filename_char`]) become a space, runs of whitespace collapse to one
|
||||
/// space, and the result is trimmed - including trailing dots, which Windows silently
|
||||
/// strips from filenames (so leaving them in would make the planned name and the
|
||||
/// actually-written name disagree).
|
||||
fn validate_file_name(value: Dynamic) -> String {
|
||||
let replaced: String =
|
||||
value.to_string().chars().map(|c| if is_illegal_filename_char(c) { ' ' } else { c }).collect();
|
||||
replaced.split_whitespace().collect::<Vec<_>>().join(" ").trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
/// True for a character FileBot's `.space()` treats as a normalizable word separator.
|
||||
fn is_separator_char(c: char) -> bool {
|
||||
c.is_whitespace() || c == '.' || c == '_'
|
||||
}
|
||||
|
||||
/// Normalizes every run of separator characters (whitespace, `.`, `_`) to a single
|
||||
/// space, then trims. Equivalent to `space_with(value, " ")`.
|
||||
fn space(value: Dynamic) -> String {
|
||||
space_with(value, " ".to_string())
|
||||
}
|
||||
|
||||
/// Same as [`space`], but replacing each separator run with `replacement` (only its
|
||||
/// first `char` is used; an empty string falls back to a space) instead of a literal
|
||||
/// space - e.g. `{n.space_with(".")}` turns `"Breaking Bad"` into `"Breaking.Bad"`.
|
||||
fn space_with(value: Dynamic, replacement: String) -> String {
|
||||
let replacement = replacement.chars().next().unwrap_or(' ');
|
||||
let s = value.to_string();
|
||||
let mut result = String::new();
|
||||
let mut last_was_sep = false;
|
||||
for c in s.chars() {
|
||||
if is_separator_char(c) {
|
||||
if !last_was_sep {
|
||||
result.push(replacement);
|
||||
}
|
||||
last_was_sep = true;
|
||||
} else {
|
||||
result.push(c);
|
||||
last_was_sep = false;
|
||||
}
|
||||
}
|
||||
result.trim_matches(replacement).to_string()
|
||||
}
|
||||
|
||||
/// Replaces every `:` with `" -"` - colons are illegal in Windows filenames but common
|
||||
/// in TV/movie titles (`"Show: Subtitle"`).
|
||||
fn colon(value: Dynamic) -> String {
|
||||
colon_with(value, " -".to_string())
|
||||
}
|
||||
|
||||
/// Same as [`colon`], but replacing `:` with a caller-chosen string instead of `" -"`.
|
||||
fn colon_with(value: Dynamic, replacement: String) -> String {
|
||||
value.to_string().replace(':', &replacement)
|
||||
}
|
||||
|
||||
pub fn register(engine: &mut Engine) {
|
||||
engine.register_fn("is_empty_value", is_empty_value);
|
||||
engine.register_fn("any", any_of);
|
||||
engine.register_fn("all_of", all_of);
|
||||
engine.register_fn("concat", concat);
|
||||
engine.register_fn("pad", pad);
|
||||
engine.register_fn("pad_with", pad_with);
|
||||
engine.register_fn("sort_name", sort_name);
|
||||
engine.register_fn("sort_initial", sort_initial);
|
||||
engine.register_fn("upper_initial", upper_initial);
|
||||
engine.register_fn("lower_initial", lower_initial);
|
||||
engine.register_fn("acronym", acronym);
|
||||
engine.register_fn("validate_file_name", validate_file_name);
|
||||
engine.register_fn("space", space);
|
||||
engine.register_fn("space_with", space_with);
|
||||
engine.register_fn("colon", colon);
|
||||
engine.register_fn("colon_with", colon_with);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn eval(expr: &str) -> String {
|
||||
let mut engine = Engine::new();
|
||||
register(&mut engine);
|
||||
engine.eval::<Dynamic>(expr).unwrap().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pad_left_pads_with_zero_by_default() {
|
||||
assert_eq!(eval(r#""5".pad(2)"#), "05");
|
||||
assert_eq!(eval(r#""5".pad(3)"#), "005");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pad_does_not_truncate_a_value_already_at_or_past_width() {
|
||||
assert_eq!(eval(r#""123".pad(2)"#), "123");
|
||||
assert_eq!(eval(r#""12".pad(2)"#), "12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pad_accepts_a_custom_pad_character() {
|
||||
assert_eq!(eval(r#""7".pad_with(3, "-")"#), "--7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_name_moves_a_leading_article_to_the_end() {
|
||||
assert_eq!(eval(r#""The Office".sort_name()"#), "Office, The");
|
||||
assert_eq!(eval(r#""A Beautiful Mind".sort_name()"#), "Beautiful Mind, A");
|
||||
assert_eq!(eval(r#""An Officer and a Gentleman".sort_name()"#), "Officer and a Gentleman, An");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_name_leaves_a_title_with_no_leading_article_unchanged() {
|
||||
assert_eq!(eval(r#""Deadpool".sort_name()"#), "Deadpool");
|
||||
// "A-Team" is not "A " + rest - no space after the article, so it doesn't match.
|
||||
assert_eq!(eval(r#""A-Team".sort_name()"#), "A-Team");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_initial_strips_the_article_before_taking_the_first_letter() {
|
||||
assert_eq!(eval(r#""The Office".sort_initial()"#), "O");
|
||||
assert_eq!(eval(r#""Deadpool".sort_initial()"#), "D");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upper_and_lower_initial_only_touch_the_first_character() {
|
||||
assert_eq!(eval(r#""breaking Bad".upper_initial()"#), "Breaking Bad");
|
||||
assert_eq!(eval(r#""Breaking Bad".lower_initial()"#), "breaking Bad");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acronym_takes_the_first_letter_of_each_word_uppercased() {
|
||||
assert_eq!(eval(r#""Attack on Titan".acronym()"#), "AOT");
|
||||
assert_eq!(eval(r#""Full Metal Alchemist Brotherhood".acronym()"#), "FMAB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_file_name_replaces_illegal_characters_with_a_space() {
|
||||
assert_eq!(eval(r#""Who Framed Roger Rabbit?".validate_file_name()"#), "Who Framed Roger Rabbit");
|
||||
assert_eq!(eval(r#""Aliens: Special Edition".validate_file_name()"#), "Aliens Special Edition");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_file_name_collapses_whitespace_and_strips_trailing_dots() {
|
||||
assert_eq!(eval(r#""Weird: /Path\\Bits*".validate_file_name()"#), "Weird Path Bits");
|
||||
assert_eq!(eval(r#""Trailing dot.".validate_file_name()"#), "Trailing dot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_normalizes_separator_runs_to_a_single_space() {
|
||||
assert_eq!(eval(r#""Breaking___Bad".space()"#), "Breaking Bad");
|
||||
assert_eq!(eval(r#""Breaking.Bad.2008".space()"#), "Breaking Bad 2008");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_with_uses_a_custom_replacement_character() {
|
||||
assert_eq!(eval(r#""Breaking Bad".space_with(".")"#), "Breaking.Bad");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colon_replaces_with_a_dash_by_default() {
|
||||
assert_eq!(eval(r#""Aliens: Special Edition".colon()"#), "Aliens - Special Edition");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colon_with_uses_a_custom_replacement() {
|
||||
assert_eq!(eval(r#""Aliens: Special Edition".colon_with(" -")"#), "Aliens - Special Edition");
|
||||
assert_eq!(eval(r#""Aliens:Special".colon_with("")"#), "AliensSpecial");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ pub struct HistoryBatch {
|
||||
|
||||
/// What happened when reverting one entry of a batch.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum RevertOutcome {
|
||||
/// For a Move batch: successfully moved back to its original path. For a Copy or
|
||||
/// Symlink batch: the copy/symlink that was created has been removed - the original
|
||||
@@ -180,6 +180,22 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
// Same gotcha and same fix as `rename::RenameOutcome` and `metadata.rs`'s
|
||||
// `FetchOutcome` - `rename_all` alone on a tagged enum doesn't reach the field
|
||||
// names inside a struct-like variant; `rename_all_fields` does. Pinned here too.
|
||||
#[test]
|
||||
fn revert_outcome_serializes_with_camel_case_field_names() {
|
||||
let reverted = RevertOutcome::Reverted { restored_path: "/media/Show/original.mkv".to_string() };
|
||||
let json = serde_json::to_string(&reverted).unwrap();
|
||||
assert!(json.contains("\"restoredPath\":\"/media/Show/original.mkv\""), "got: {json}");
|
||||
assert!(!json.contains("restored_path"));
|
||||
|
||||
let conflict = RevertOutcome::SkippedConflict { existing_path: "/media/Show/original.mkv".to_string() };
|
||||
let json = serde_json::to_string(&conflict).unwrap();
|
||||
assert!(json.contains("\"existingPath\":\"/media/Show/original.mkv\""), "got: {json}");
|
||||
assert!(!json.contains("existing_path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverts_a_renamed_file_back_to_its_original_path() {
|
||||
let dir = temp_dir();
|
||||
|
||||
@@ -93,7 +93,7 @@ impl Default for ConflictPolicy {
|
||||
|
||||
/// What happened to one planned rename.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum RenameOutcome {
|
||||
/// Renamed successfully; `new_path` is the file's new full path.
|
||||
Renamed { new_path: String },
|
||||
@@ -329,6 +329,28 @@ mod tests {
|
||||
(dir, file)
|
||||
}
|
||||
|
||||
// `#[serde(tag = "status", rename_all = "camelCase")]` alone only renames the tag's
|
||||
// own values ("Renamed", "SkippedConflict") to their lowercase/camelCase form - it
|
||||
// does NOT rename the field names inside a struct-like variant the way a plain
|
||||
// (non-tagged) `#[serde(rename_all = "camelCase")]` struct would. Without the
|
||||
// separate `rename_all_fields = "camelCase"` this enum now also carries,
|
||||
// `Renamed { new_path }` serialized as a literal `new_path` key, and the frontend's
|
||||
// `newPath`/`existingPath` destructuring silently got `undefined` for both - a real
|
||||
// bug caught in `metadata.rs`'s `FetchOutcome` sibling of this exact pattern (see
|
||||
// that module's `serde_wire_format` tests). This pins the fix here too.
|
||||
#[test]
|
||||
fn rename_outcome_serializes_with_camel_case_field_names() {
|
||||
let renamed = RenameOutcome::Renamed { new_path: "/media/Show/S01E01.mkv".to_string() };
|
||||
let json = serde_json::to_string(&renamed).unwrap();
|
||||
assert!(json.contains("\"newPath\":\"/media/Show/S01E01.mkv\""), "got: {json}");
|
||||
assert!(!json.contains("new_path"));
|
||||
|
||||
let conflict = RenameOutcome::SkippedConflict { existing_path: "/media/Show/S01E01.mkv".to_string() };
|
||||
let json = serde_json::to_string(&conflict).unwrap();
|
||||
assert!(json.contains("\"existingPath\":\"/media/Show/S01E01.mkv\""), "got: {json}");
|
||||
assert!(!json.contains("existing_path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renames_a_file_successfully() {
|
||||
let (dir, file) = temp_file_setup("old.txt", "hello");
|
||||
|
||||
@@ -98,6 +98,48 @@ pub fn compute_checksum(path: &Path, kind: ChecksumKind) -> io::Result<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// All three checksums for one file, computed together - see [`compute_all_checksums`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AllChecksums {
|
||||
pub crc32: String,
|
||||
pub md5: String,
|
||||
pub sha1: String,
|
||||
}
|
||||
|
||||
/// Same job as calling [`compute_checksum`] three times (once per [`ChecksumKind`]),
|
||||
/// but streams the file exactly once instead of three times - used for the naming-scheme
|
||||
/// `{crc32}`/`{md5}`/`{sha1}` fields (see `src-tauri/src/sfv.rs`'s `compute_naming_hashes`),
|
||||
/// where a scheme referencing more than one hash field would otherwise hash the same
|
||||
/// (often multi-gigabyte video) file redundantly. The SFV panel itself still calls
|
||||
/// [`compute_checksum`] directly - it only ever needs one algorithm at a time, picked by
|
||||
/// the user, so tripling its I/O for the other two nobody asked for would be wasteful in
|
||||
/// the other direction.
|
||||
pub fn compute_all_checksums(path: &Path) -> io::Result<AllChecksums> {
|
||||
let mut file = File::open(path)?;
|
||||
let mut buf = [0u8; READ_BUFFER_SIZE];
|
||||
|
||||
let mut crc32 = crc32fast::Hasher::new();
|
||||
let mut md5 = Md5::new();
|
||||
let mut sha1 = Sha1::new();
|
||||
|
||||
loop {
|
||||
let n = file.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
let chunk = &buf[..n];
|
||||
crc32.update(chunk);
|
||||
md5.update(chunk);
|
||||
sha1.update(chunk);
|
||||
}
|
||||
|
||||
Ok(AllChecksums {
|
||||
crc32: format!("{:08X}", crc32.finalize()),
|
||||
md5: format!("{:x}", md5.finalize()),
|
||||
sha1: format!("{:x}", sha1.finalize()),
|
||||
})
|
||||
}
|
||||
|
||||
/// One entry parsed out of an existing checksum file: the file name it references
|
||||
/// (not yet resolved to a real path - checksum files reference files by name only,
|
||||
/// always relative to wherever the checksum file itself lives) and the checksum text
|
||||
@@ -293,6 +335,44 @@ mod tests {
|
||||
assert!(compute_checksum(&missing, ChecksumKind::Crc32).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_all_checksums_matches_the_known_test_vectors_all_at_once() {
|
||||
// Same "123456789" CRC32 vector as above, computed alongside MD5/SHA-1 of the
|
||||
// same bytes in one pass - pins that streaming all three hashers together over
|
||||
// the same chunks produces the identical result as hashing separately.
|
||||
let dir = temp_dir();
|
||||
let path = dir.join("check.txt");
|
||||
fs::write(&path, b"123456789").unwrap();
|
||||
let all = compute_all_checksums(&path).unwrap();
|
||||
assert_eq!(all.crc32, "CBF43926");
|
||||
assert_eq!(all.md5, "25f9e794323b453885f5181f1b624d0b"); // md5("123456789")
|
||||
assert_eq!(all.sha1, "f7c3bc1d808e04732adf679965ccc34ca7ae3441"); // sha1("123456789")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_all_checksums_agrees_with_compute_checksum_on_a_multi_chunk_file() {
|
||||
// Bigger than READ_BUFFER_SIZE (64 KiB) so both the individual and combined
|
||||
// paths each have to loop over more than one read() - catches a bug where one
|
||||
// of the three hashers only got the first chunk (e.g. an accidental `break`
|
||||
// inside one match arm instead of the shared loop).
|
||||
let dir = temp_dir();
|
||||
let path = dir.join("big.bin");
|
||||
let bytes: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
|
||||
fs::write(&path, &bytes).unwrap();
|
||||
|
||||
let all = compute_all_checksums(&path).unwrap();
|
||||
assert_eq!(all.crc32, compute_checksum(&path, ChecksumKind::Crc32).unwrap());
|
||||
assert_eq!(all.md5, compute_checksum(&path, ChecksumKind::Md5).unwrap());
|
||||
assert_eq!(all.sha1, compute_checksum(&path, ChecksumKind::Sha1).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_all_checksums_errors_when_the_file_does_not_exist() {
|
||||
let dir = temp_dir();
|
||||
let missing = dir.join("nope.txt");
|
||||
assert!(compute_all_checksums(&missing).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_a_typical_sfv_file_skipping_comments_and_blank_lines() {
|
||||
let content = "; Generated by Aurora Curator\n\nShow.S01E01.mkv CBF43926\nShow.S01E02.mkv 12345678\n";
|
||||
|
||||
@@ -97,3 +97,32 @@ fn genuinely_invalid_syntax_with_spaces_still_errors() {
|
||||
let err = ExpressionFormat::compile("{show name (}").unwrap_err();
|
||||
assert!(err.to_string().contains("syntax error"), "message was: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripting_helpers_pad_and_sort_name_work_in_a_naming_scheme() {
|
||||
// A realistic naming scheme leaning on the newly-ported scripting/naming helpers:
|
||||
// zero-padded season/episode numbers plus a sort-friendly show name for a "by title"
|
||||
// library layout.
|
||||
let fmt = ExpressionFormat::compile("{n.sort_name()}/S{s.pad(2)}E{e.pad(2)} - {t}").unwrap();
|
||||
let bindings = string_bindings([
|
||||
("n", "The Office"),
|
||||
("s", "5"),
|
||||
("e", "14"),
|
||||
("t", "Stress Relief"),
|
||||
]);
|
||||
assert_eq!(fmt.format(&bindings).unwrap(), "Office, The/S05E14 - Stress Relief");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripting_helper_validate_file_name_sanitizes_a_title_with_illegal_characters() {
|
||||
let fmt = ExpressionFormat::compile("{t.colon().validate_file_name()}").unwrap();
|
||||
let bindings = string_bindings([("t", "Aliens: Special Edition?")]);
|
||||
assert_eq!(fmt.format(&bindings).unwrap(), "Aliens - Special Edition");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripting_helper_acronym_and_space_with_combine_in_an_expression() {
|
||||
let fmt = ExpressionFormat::compile(r#"{n.acronym()}_{n.space_with("_")}"#).unwrap();
|
||||
let bindings = string_bindings([("n", "Attack on Titan")]);
|
||||
assert_eq!(fmt.format(&bindings).unwrap(), "AOT_Attack_on_Titan");
|
||||
}
|
||||
|
||||
@@ -46,8 +46,9 @@ Options:
|
||||
movies are also looked up online when this is tmdb or omdb - TVmaze has no movie catalog)
|
||||
--action <move|copy|symlink|test> What to do with each file (default: move; \"test\" prints the plan without touching anything)
|
||||
--conflict <skip|auto-number|override> What to do when a computed target already exists (default: skip)
|
||||
-non-strict Accepted for FileBot script compatibility; has no effect (local matching has no
|
||||
confidence score to loosen - see the aurora_curator_core::cli module doc)
|
||||
-non-strict Accepted for compatibility with existing rename scripts; has no effect (local
|
||||
matching has no confidence score to loosen - see the aurora_curator_core::cli
|
||||
module doc)
|
||||
-h, --help Show this help and exit
|
||||
|
||||
A --db tmdb/omdb source needs the matching API token/key already saved through the app's own Fetch Data
|
||||
|
||||
@@ -21,7 +21,7 @@ pub struct EpisodeRow {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum EpisodeListOutcome {
|
||||
Found {
|
||||
series_name: String,
|
||||
@@ -90,4 +90,20 @@ mod tests {
|
||||
assert_eq!(row.title, "Ozymandias");
|
||||
assert_eq!(row.air_date.as_deref(), Some("2013-09-15"));
|
||||
}
|
||||
|
||||
// `#[serde(tag = "status", rename_all = "camelCase")]` alone only renames the tag's
|
||||
// own values, not the field names inside a struct-like variant - see
|
||||
// `metadata.rs`'s `serde_wire_format` tests for the full story and the bug this
|
||||
// caught. `rename_all_fields = "camelCase"` on this enum is what actually renames
|
||||
// `series_name` to `seriesName` on the wire; this test pins that.
|
||||
#[test]
|
||||
fn episode_list_outcome_found_serializes_with_camel_case_field_names() {
|
||||
let outcome = EpisodeListOutcome::Found {
|
||||
series_name: "Breaking Bad".to_string(),
|
||||
episodes: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&outcome).unwrap();
|
||||
assert!(json.contains("\"seriesName\":\"Breaking Bad\""), "got: {json}");
|
||||
assert!(!json.contains("series_name"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,12 +119,19 @@ pub fn run() {
|
||||
settings::get_opensubtitles_username,
|
||||
settings::has_opensubtitles_password,
|
||||
settings::save_opensubtitles_login,
|
||||
settings::get_last_naming_scheme,
|
||||
settings::save_last_naming_scheme,
|
||||
settings::get_last_movie_naming_scheme,
|
||||
settings::save_last_movie_naming_scheme,
|
||||
settings::get_theme,
|
||||
settings::save_theme,
|
||||
subtitles::search_subtitles,
|
||||
subtitles::download_subtitle,
|
||||
episodes::browse_episodes,
|
||||
sfv::compute_checksums,
|
||||
sfv::save_checksum_file,
|
||||
sfv::verify_checksum_file,
|
||||
sfv::compute_naming_hashes,
|
||||
list::export_text_list,
|
||||
probe::probe_media_files,
|
||||
history::list_rename_history,
|
||||
|
||||
@@ -31,7 +31,7 @@ pub struct FetchQuery {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum FetchOutcome {
|
||||
Found {
|
||||
series_name: String,
|
||||
@@ -298,7 +298,7 @@ pub struct MovieFetchQuery {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum MovieFetchOutcome {
|
||||
Found {
|
||||
title: String,
|
||||
@@ -371,3 +371,45 @@ async fn run_movie_fetch(client: AnyMovieClient, items: Vec<MovieFetchQuery>) ->
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod serde_wire_format {
|
||||
use super::*;
|
||||
|
||||
// `#[serde(tag = "status", rename_all = "camelCase")]` on an enum only renames the
|
||||
// tag's own values (`Found` -> "found") - NOT the field names inside a struct-like
|
||||
// variant, which is a genuinely easy thing to miss since a plain (non-tagged)
|
||||
// `#[serde(rename_all = "camelCase")]` struct DOES rename its fields. Without the
|
||||
// separate `rename_all_fields = "camelCase"` this crate now also carries, `Found {
|
||||
// series_name, episode_title, air_date }` serialized as literal `series_name`/
|
||||
// `episode_title`/`air_date` keys - the frontend's `const { seriesName, episodeTitle
|
||||
// } = tvResult.outcome` destructured `undefined` for both, which then silently
|
||||
// vanished (JSON.stringify drops `undefined` properties) by the time bindings
|
||||
// reached `render_expression`, rendering `{show name}`/`{episode name}` as empty
|
||||
// even for entries the status line correctly reported as "found". These two tests
|
||||
// pin the fix so the same enum shape can't quietly regress it.
|
||||
#[test]
|
||||
fn fetch_outcome_found_serializes_with_camel_case_field_names() {
|
||||
let outcome = FetchOutcome::Found {
|
||||
series_name: "Fire Country".to_string(),
|
||||
episode_title: "Pilot".to_string(),
|
||||
air_date: Some("2024-01-01".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&outcome).unwrap();
|
||||
assert!(json.contains("\"seriesName\":\"Fire Country\""), "got: {json}");
|
||||
assert!(json.contains("\"episodeTitle\":\"Pilot\""), "got: {json}");
|
||||
assert!(json.contains("\"airDate\":\"2024-01-01\""), "got: {json}");
|
||||
assert!(!json.contains("series_name") && !json.contains("episode_title") && !json.contains("air_date"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movie_fetch_outcome_found_serializes_with_camel_case_field_names() {
|
||||
let outcome = MovieFetchOutcome::Found {
|
||||
title: "Inception".to_string(),
|
||||
release_date: Some("2010-07-16".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&outcome).unwrap();
|
||||
assert!(json.contains("\"releaseDate\":\"2010-07-16\""), "got: {json}");
|
||||
assert!(!json.contains("release_date"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,32 @@ struct StoredSettings {
|
||||
/// [`has_opensubtitles_password`], which only reports whether one is set.
|
||||
#[serde(default)]
|
||||
opensubtitles_password: Option<String>,
|
||||
/// The naming-scheme expression the Rename panel's editor last held - saved on
|
||||
/// every edit (debounced on the frontend) and restored on startup, so a custom
|
||||
/// scheme survives closing and reopening the app instead of resetting to the
|
||||
/// built-in sample expression every time. Distinct from `presets.rs`'s 1-9 saved
|
||||
/// slots: this is the *current* scheme, not an explicitly named save.
|
||||
///
|
||||
/// Used for TV episodes specifically - see [`last_movie_naming_scheme`] for the
|
||||
/// separate scheme movies use. The two are independent because TV and movie items
|
||||
/// carry entirely different binding vocabularies (season/episode numbers and an
|
||||
/// episode title vs. just a movie title and year); a single shared scheme text
|
||||
/// can only ever render one of the two kinds correctly; a batch that mixes TV and
|
||||
/// movies would leave the other kind's fields blank.
|
||||
#[serde(default)]
|
||||
last_naming_scheme: Option<String>,
|
||||
/// The naming-scheme expression the Rename panel's editor last held while in
|
||||
/// "Movies" mode - same persistence behavior as [`last_naming_scheme`], just for
|
||||
/// movie items. See that field's doc for why these are two separate settings.
|
||||
#[serde(default)]
|
||||
last_movie_naming_scheme: Option<String>,
|
||||
/// Which visual theme the app renders with - a plain string id ("aurora"/"light"/
|
||||
/// "ocean"/"sunset") matching a `[data-theme="..."]` block in `src/theme.css` on
|
||||
/// the frontend. Kept as an opaque string here rather than a Rust enum so a new
|
||||
/// theme can be added purely in CSS/TS without touching this file too; an
|
||||
/// unrecognized or missing value just falls back to the frontend's own default.
|
||||
#[serde(default)]
|
||||
theme: Option<String>,
|
||||
}
|
||||
|
||||
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
@@ -294,6 +320,75 @@ pub fn save_opensubtitles_login(app: AppHandle, username: String, password: Stri
|
||||
save(&app, &settings)
|
||||
}
|
||||
|
||||
/// Returns the last-saved naming-scheme expression, or `None` if nothing (or only
|
||||
/// blank text) has been saved yet - the frontend falls back to its own built-in
|
||||
/// sample expression in that case.
|
||||
pub fn last_naming_scheme(app: &AppHandle) -> Option<String> {
|
||||
load(app).last_naming_scheme.filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_last_naming_scheme(app: AppHandle) -> Option<String> {
|
||||
last_naming_scheme(&app)
|
||||
}
|
||||
|
||||
/// Saves the naming-scheme editor's current text so it survives an app restart. The
|
||||
/// frontend debounces calls to this while the user is actively typing (see
|
||||
/// `RenamePanel.tsx`) rather than saving on every keystroke. Unlike
|
||||
/// `presets::save_preset`, an empty expression is accepted here (it just clears the
|
||||
/// saved value, same as every other credential field in this module) rather than
|
||||
/// rejected - the editor's text field can legitimately be emptied while editing.
|
||||
#[tauri::command]
|
||||
pub fn save_last_naming_scheme(app: AppHandle, expression: String) -> Result<(), String> {
|
||||
let mut settings = load(&app);
|
||||
let trimmed = expression.trim().to_string();
|
||||
settings.last_naming_scheme = if trimmed.is_empty() { None } else { Some(expression) };
|
||||
save(&app, &settings)
|
||||
}
|
||||
|
||||
/// Returns the last-saved *movie* naming-scheme expression, or `None` if nothing (or
|
||||
/// only blank text) has been saved yet - the frontend falls back to its own built-in
|
||||
/// movie default in that case. See [`last_naming_scheme`] for why movies get their
|
||||
/// own separate saved scheme instead of sharing the TV one.
|
||||
pub fn last_movie_naming_scheme(app: &AppHandle) -> Option<String> {
|
||||
load(app).last_movie_naming_scheme.filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_last_movie_naming_scheme(app: AppHandle) -> Option<String> {
|
||||
last_movie_naming_scheme(&app)
|
||||
}
|
||||
|
||||
/// Saves the movie naming-scheme editor's current text so it survives an app restart.
|
||||
/// Mirrors [`save_last_naming_scheme`] exactly, just for the separate movie scheme.
|
||||
#[tauri::command]
|
||||
pub fn save_last_movie_naming_scheme(app: AppHandle, expression: String) -> Result<(), String> {
|
||||
let mut settings = load(&app);
|
||||
let trimmed = expression.trim().to_string();
|
||||
settings.last_movie_naming_scheme = if trimmed.is_empty() { None } else { Some(expression) };
|
||||
save(&app, &settings)
|
||||
}
|
||||
|
||||
/// Returns the stored theme id, or `None` if nothing's been saved yet (the frontend
|
||||
/// then falls back to its own built-in default theme).
|
||||
pub fn theme(app: &AppHandle) -> Option<String> {
|
||||
load(app).theme.filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_theme(app: AppHandle) -> Option<String> {
|
||||
theme(&app)
|
||||
}
|
||||
|
||||
/// Saves which theme id the user picked in Settings.
|
||||
#[tauri::command]
|
||||
pub fn save_theme(app: AppHandle, theme: String) -> Result<(), String> {
|
||||
let mut settings = load(&app);
|
||||
let trimmed = theme.trim().to_string();
|
||||
settings.theme = if trimmed.is_empty() { None } else { Some(trimmed) };
|
||||
save(&app, &settings)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -372,6 +467,76 @@ mod tests {
|
||||
assert_eq!(parsed.opensubtitles_password, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_settings_without_a_last_naming_scheme_field_still_deserializes() {
|
||||
// Same schema-evolution guarantee as the OpenSubtitles test above, for the
|
||||
// field this naming-scheme-persistence fix added.
|
||||
let old_json = r#"{"tmdb_token":"abc123"}"#;
|
||||
let parsed: StoredSettings = serde_json::from_str(old_json).unwrap();
|
||||
assert_eq!(parsed.last_naming_scheme, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_settings_without_movie_scheme_or_theme_fields_still_deserializes() {
|
||||
// Same schema-evolution guarantee, for the two fields the movie-naming-scheme
|
||||
// split and the theming feature added.
|
||||
let old_json = r#"{"tmdb_token":"abc123","last_naming_scheme":"{show name}"}"#;
|
||||
let parsed: StoredSettings = serde_json::from_str(old_json).unwrap();
|
||||
assert_eq!(parsed.last_naming_scheme, Some("{show name}".to_string()));
|
||||
assert_eq!(parsed.last_movie_naming_scheme, None);
|
||||
assert_eq!(parsed.theme, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_movie_naming_scheme_round_trips_and_ignores_pure_whitespace() {
|
||||
// Mirrors last_naming_scheme_round_trips_and_ignores_pure_whitespace below,
|
||||
// for the separate movie scheme.
|
||||
let mut settings = StoredSettings::default();
|
||||
settings.last_movie_naming_scheme = Some("{movie name} ({year})".to_string());
|
||||
assert_eq!(
|
||||
settings.last_movie_naming_scheme.as_deref().filter(|t| !t.trim().is_empty()),
|
||||
Some("{movie name} ({year})")
|
||||
);
|
||||
|
||||
settings.last_movie_naming_scheme = Some(" ".to_string());
|
||||
assert_eq!(
|
||||
settings.last_movie_naming_scheme.as_deref().filter(|t| !t.trim().is_empty()),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_round_trips_and_ignores_pure_whitespace() {
|
||||
let mut settings = StoredSettings::default();
|
||||
settings.theme = Some("ocean".to_string());
|
||||
assert_eq!(settings.theme.as_deref().filter(|t| !t.trim().is_empty()), Some("ocean"));
|
||||
|
||||
settings.theme = Some(" ".to_string());
|
||||
assert_eq!(settings.theme.as_deref().filter(|t| !t.trim().is_empty()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_naming_scheme_round_trips_and_ignores_pure_whitespace() {
|
||||
// Pure merge logic (load/save both need a real AppHandle, exercised end to end
|
||||
// by the Playwright pass instead - same reasoning as every other credential
|
||||
// test in this file): a saved non-blank expression comes back unchanged,
|
||||
// preserving internal spacing exactly (naming schemes can have meaningful
|
||||
// literal spaces, e.g. " - " between fields), and a whitespace-only save reads
|
||||
// back as unset, matching the empty-string behavior of every other field here.
|
||||
let mut settings = StoredSettings::default();
|
||||
settings.last_naming_scheme = Some("{show name} - {episode name}".to_string());
|
||||
assert_eq!(
|
||||
settings.last_naming_scheme.as_deref().filter(|t| !t.trim().is_empty()),
|
||||
Some("{show name} - {episode name}")
|
||||
);
|
||||
|
||||
settings.last_naming_scheme = Some(" ".to_string());
|
||||
assert_eq!(
|
||||
settings.last_naming_scheme.as_deref().filter(|t| !t.trim().is_empty()),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_login_update_keeps_the_other_field_when_one_is_blank() {
|
||||
// Updating just the password shouldn't silently clear a previously-saved
|
||||
|
||||
@@ -53,6 +53,51 @@ pub async fn compute_checksums(paths: Vec<String>, kind: ChecksumKind) -> Vec<Ch
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// All three checksums for one file, for the naming-scheme `{crc32}`/`{md5}`/`{sha1}`
|
||||
/// fields (see `RenamePanel.tsx`'s `runHashFiles`) - unlike `ChecksumOutcome` above,
|
||||
/// which is one algorithm at a time for the SFV panel, this is all three at once so a
|
||||
/// naming scheme referencing more than one of them doesn't hash the same file three
|
||||
/// times over. `error` is set (and all three checksums left `None`) if hashing this
|
||||
/// particular file failed - kept per-file, same as `ChecksumOutcome`, so one bad file
|
||||
/// doesn't abort the whole batch.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NamingHashOutcome {
|
||||
pub path: String,
|
||||
pub crc32: Option<String>,
|
||||
pub md5: Option<String>,
|
||||
pub sha1: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Computes CRC32 + MD5 + SHA-1 for every path in `paths` in one pass per file (see
|
||||
/// `aurora_curator_core::sfv::compute_all_checksums`). Same blocking-thread treatment
|
||||
/// as `compute_checksums` - real file I/O, potentially large video files - and
|
||||
/// deliberately a separate, explicit action from "Match" rather than something that
|
||||
/// runs automatically: hashing a whole file is much slower than Match's other local
|
||||
/// steps (filename parsing, container-header probing), which only read a small header,
|
||||
/// not the entire file.
|
||||
#[tauri::command]
|
||||
pub async fn compute_naming_hashes(paths: Vec<String>) -> Vec<NamingHashOutcome> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
paths
|
||||
.into_iter()
|
||||
.map(|path| match sfv::compute_all_checksums(Path::new(&path)) {
|
||||
Ok(all) => NamingHashOutcome {
|
||||
path,
|
||||
crc32: Some(all.crc32),
|
||||
md5: Some(all.md5),
|
||||
sha1: Some(all.sha1),
|
||||
error: None,
|
||||
},
|
||||
Err(err) => NamingHashOutcome { path, crc32: None, md5: None, sha1: None, error: Some(err.to_string()) },
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// One row the frontend already has (a computed checksum for a loaded file) - the
|
||||
/// input shape `save_checksum_file` builds a sidecar file's text content from.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
|
||||
+17
-1
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import Sidebar, { PANELS } from "./components/Sidebar";
|
||||
import Header from "./components/Header";
|
||||
import RenamePanel from "./components/RenamePanel";
|
||||
@@ -8,11 +9,26 @@ import SfvPanel from "./components/SfvPanel";
|
||||
import FilterPanel from "./components/FilterPanel";
|
||||
import ListPanel from "./components/ListPanel";
|
||||
import PanelStub from "./components/PanelStub";
|
||||
import { applyTheme, DEFAULT_THEME, type ThemeId } from "./components/SettingsModal";
|
||||
|
||||
const THEME_IDS: ThemeId[] = ["aurora", "white", "black", "ocean", "sunset"];
|
||||
|
||||
export default function App() {
|
||||
const [selected, setSelected] = useState(PANELS[0].id);
|
||||
const current = PANELS.find((p) => p.id === selected) ?? PANELS[0];
|
||||
|
||||
// Applies the last-saved theme as soon as the app boots, regardless of whether the
|
||||
// Settings modal (SettingsModal.tsx, opened from the sidebar) ever gets opened this
|
||||
// session - without this, a saved theme would only ever take effect once the user
|
||||
// happened to open Settings again after restarting the app.
|
||||
useEffect(() => {
|
||||
invoke<string | null>("get_theme")
|
||||
.then((saved) => {
|
||||
applyTheme(saved && (THEME_IDS as string[]).includes(saved) ? (saved as ThemeId) : DEFAULT_THEME);
|
||||
})
|
||||
.catch(() => applyTheme(DEFAULT_THEME));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Sidebar selected={selected} onSelect={setSelected} />
|
||||
|
||||
@@ -29,6 +29,32 @@ interface ReleaseInfo {
|
||||
group: string | null;
|
||||
}
|
||||
|
||||
// Result of real container-header probing (aurora-curator-core's `probe` module, exposed
|
||||
// via the batched `probe_media_files` Tauri command) - resolution/codecs read straight
|
||||
// from the file's own header, not guessed from its name. Complements (never overrides)
|
||||
// `ReleaseInfo` above, same as the Rename and List panels: a filename-tag value wins
|
||||
// whenever a file has both.
|
||||
interface MediaProbe {
|
||||
fileSizeBytes: number;
|
||||
durationSeconds: number | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
frameRate: number | null;
|
||||
videoCodec: string | null;
|
||||
audioCodec: string | null;
|
||||
container: string | null;
|
||||
resolution: string | null;
|
||||
duration: string | null;
|
||||
fileSize: string;
|
||||
}
|
||||
|
||||
interface ProbeOutcome {
|
||||
path: string;
|
||||
probe: MediaProbe;
|
||||
}
|
||||
|
||||
const UNKNOWN = "(unknown)";
|
||||
|
||||
type MediaKind = "tv" | "movie" | "unmatched";
|
||||
|
||||
interface FileInfo {
|
||||
@@ -39,6 +65,12 @@ interface FileInfo {
|
||||
movieName?: string;
|
||||
year?: number | null;
|
||||
release: ReleaseInfo | null;
|
||||
// Merged resolution/video codec - a filename tag wins when present, otherwise falls
|
||||
// back to real container probing. Every file gets a chance at both, including an
|
||||
// otherwise-unmatched one (see `scan` below).
|
||||
resolution: string | null;
|
||||
videoCodec: string | null;
|
||||
probed: boolean;
|
||||
}
|
||||
|
||||
const KIND_LABELS: Record<MediaKind, string> = {
|
||||
@@ -57,14 +89,15 @@ function displayName(file: FileEntry): string {
|
||||
|
||||
function subtitleFor(info: FileInfo | undefined): string {
|
||||
if (!info) return "";
|
||||
const tag = info.resolution ? ` · ${info.resolution}` : "";
|
||||
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;
|
||||
return (info.seriesName ? `${info.seriesName} · ${se}` : se) + tag;
|
||||
}
|
||||
if (info.kind === "movie") {
|
||||
return info.year ? `${info.movieName} (${info.year})` : info.movieName ?? "";
|
||||
return (info.year ? `${info.movieName} (${info.year})` : info.movieName ?? "") + tag;
|
||||
}
|
||||
return "Unmatched";
|
||||
return "Unmatched" + tag;
|
||||
}
|
||||
|
||||
export default function FilterPanel() {
|
||||
@@ -73,6 +106,8 @@ export default function FilterPanel() {
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [extensions, setExtensions] = useState<Set<string>>(new Set());
|
||||
const [kinds, setKinds] = useState<Set<MediaKind>>(new Set());
|
||||
const [resolutions, setResolutions] = useState<Set<string>>(new Set());
|
||||
const [videoCodecs, setVideoCodecs] = useState<Set<string>>(new Set());
|
||||
const [nameContains, setNameContains] = useState("");
|
||||
const [season, setSeason] = useState("");
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
@@ -91,6 +126,13 @@ export default function FilterPanel() {
|
||||
async function scan(targets: FileEntry[]) {
|
||||
setScanning(true);
|
||||
try {
|
||||
// One batched round trip for real probing of every target, run alongside the
|
||||
// per-file filename guessing below rather than after it - same pattern as the
|
||||
// Rename and List panels.
|
||||
const probePromise = invoke<ProbeOutcome[]>("probe_media_files", {
|
||||
paths: targets.map((file) => file.path),
|
||||
}).catch(() => [] as ProbeOutcome[]);
|
||||
|
||||
const results = await Promise.all(
|
||||
targets.map(async (file): Promise<readonly [string, FileInfo]> => {
|
||||
const releasePromise = invoke<ReleaseInfo | null>("guess_release_info", {
|
||||
@@ -107,6 +149,9 @@ export default function FilterPanel() {
|
||||
season: episode.season,
|
||||
episode: episode.episode,
|
||||
release: null,
|
||||
resolution: null,
|
||||
videoCodec: null,
|
||||
probed: false,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
@@ -116,31 +161,61 @@ export default function FilterPanel() {
|
||||
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 };
|
||||
fileInfo = {
|
||||
kind: "movie",
|
||||
movieName: movie.title,
|
||||
year: movie.year,
|
||||
release: null,
|
||||
resolution: null,
|
||||
videoCodec: null,
|
||||
probed: false,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Neither guess worked.
|
||||
}
|
||||
}
|
||||
if (!fileInfo) fileInfo = { kind: "unmatched", release: null };
|
||||
if (!fileInfo) {
|
||||
fileInfo = { kind: "unmatched", release: null, resolution: null, videoCodec: null, probed: false };
|
||||
}
|
||||
fileInfo.release = await releasePromise;
|
||||
return [file.path, fileInfo] as const;
|
||||
})
|
||||
);
|
||||
|
||||
const probes = new Map((await probePromise).map((outcome) => [outcome.path, outcome.probe] as const));
|
||||
|
||||
const nextInfo: Record<string, FileInfo> = {};
|
||||
const seenExt = new Set<string>();
|
||||
const seenKinds = new Set<MediaKind>();
|
||||
const seenRes = new Set<string>();
|
||||
const seenCodec = new Set<string>();
|
||||
let probedCount = 0;
|
||||
for (const [path, fileInfo] of results) {
|
||||
const probe = probes.get(path);
|
||||
// Real-probed resolution/video codec only fill in when the filename tag
|
||||
// above didn't already provide them - same override rule the Rename and List
|
||||
// panels use. A file with neither still gets a chance at both, even one
|
||||
// Match couldn't recognize as TV or a movie at all.
|
||||
fileInfo.resolution = fileInfo.release?.resolution ?? probe?.resolution ?? null;
|
||||
fileInfo.videoCodec = fileInfo.release?.videoCodec ?? probe?.videoCodec ?? null;
|
||||
fileInfo.probed = probe?.container != null;
|
||||
if (fileInfo.probed) probedCount += 1;
|
||||
nextInfo[path] = fileInfo;
|
||||
seenKinds.add(fileInfo.kind);
|
||||
seenRes.add(fileInfo.resolution ?? UNKNOWN);
|
||||
seenCodec.add(fileInfo.videoCodec ?? UNKNOWN);
|
||||
}
|
||||
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).`);
|
||||
setResolutions(seenRes);
|
||||
setVideoCodecs(seenCodec);
|
||||
setStatus(
|
||||
`Scanned ${targets.length} file${targets.length === 1 ? "" : "s"} locally (${probedCount > 0 ? `${probedCount} probed from file; ` : ""}no network calls).`
|
||||
);
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
@@ -168,10 +243,33 @@ export default function FilterPanel() {
|
||||
});
|
||||
}
|
||||
|
||||
function toggleResolution(res: string) {
|
||||
setResolutions((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(res)) next.delete(res);
|
||||
else next.add(res);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleVideoCodec(codec: string) {
|
||||
setVideoCodecs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(codec)) next.delete(codec);
|
||||
else next.add(codec);
|
||||
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)
|
||||
);
|
||||
// Real container probing is best-effort, so these rows only show up once something
|
||||
// actually has a real value - a batch of files with no recognizable resolution/codec
|
||||
// at all (e.g. non-media files) doesn't get a useless "Unknown"-only row.
|
||||
const allResolutions = Array.from(new Set(Object.values(info).map((i) => i.resolution ?? UNKNOWN))).sort();
|
||||
const allVideoCodecs = Array.from(new Set(Object.values(info).map((i) => i.videoCodec ?? UNKNOWN))).sort();
|
||||
|
||||
const seasonFilter = kinds.has("tv") && season.trim() !== "" ? Number(season) : null;
|
||||
|
||||
@@ -180,6 +278,8 @@ export default function FilterPanel() {
|
||||
if (!fileInfo) return false;
|
||||
if (!extensions.has(file.ext)) return false;
|
||||
if (!kinds.has(fileInfo.kind)) return false;
|
||||
if (!resolutions.has(fileInfo.resolution ?? UNKNOWN)) return false;
|
||||
if (!videoCodecs.has(fileInfo.videoCodec ?? UNKNOWN)) return false;
|
||||
if (nameContains.trim() && !displayName(file).toLowerCase().includes(nameContains.trim().toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
@@ -244,6 +344,40 @@ export default function FilterPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allResolutions.some((r) => r !== UNKNOWN) && (
|
||||
<div className="filter-criteria-row">
|
||||
<span className="filter-criteria-label">Resolution</span>
|
||||
<div className="filter-chip-row" role="group" aria-label="Filter by resolution">
|
||||
{allResolutions.map((res) => (
|
||||
<button
|
||||
key={res}
|
||||
className={`filter-chip${resolutions.has(res) ? " selected" : ""}`}
|
||||
onClick={() => toggleResolution(res)}
|
||||
>
|
||||
{res === UNKNOWN ? "Unknown" : res}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allVideoCodecs.some((c) => c !== UNKNOWN) && (
|
||||
<div className="filter-criteria-row">
|
||||
<span className="filter-criteria-label">Video codec</span>
|
||||
<div className="filter-chip-row" role="group" aria-label="Filter by video codec">
|
||||
{allVideoCodecs.map((codec) => (
|
||||
<button
|
||||
key={codec}
|
||||
className={`filter-chip${videoCodecs.has(codec) ? " selected" : ""}`}
|
||||
onClick={() => toggleVideoCodec(codec)}
|
||||
>
|
||||
{codec === UNKNOWN ? "Unknown" : codec}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="filter-criteria-row">
|
||||
<span className="filter-criteria-label">Filename contains</span>
|
||||
<input
|
||||
@@ -272,7 +406,12 @@ export default function FilterPanel() {
|
||||
)}
|
||||
|
||||
<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-empty-note">
|
||||
Load files to filter them by extension, kind, name, season, or (when real container probing or a
|
||||
filename tag finds one) resolution and video codec.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="filter-results-header">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(180deg, #111e26 0%, var(--surface-alt) 62%, #0a161c 100%);
|
||||
background: linear-gradient(180deg, var(--header-grad-a) 0%, var(--surface-alt) 62%, var(--header-grad-b) 100%);
|
||||
border-bottom: 1px solid var(--dock-line);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -153,3 +153,26 @@ export function KeyIcon() {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function HashIcon() {
|
||||
return (
|
||||
<svg {...{ ...base, width: 15, height: 15 }}>
|
||||
<path d="M9 3.5L6.5 20.5M17.5 3.5L15 20.5M4 8.5h16M3 15.5h16" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Used for the app-wide Settings entry (theme picker) in the sidebar footer -
|
||||
// deliberately a palette rather than reusing GearIcon, which already means something
|
||||
// different (the per-file Naming Scheme editor's trigger button in the Rename panel).
|
||||
export function PaletteIcon() {
|
||||
return (
|
||||
<svg {...{ ...base, width: 16, height: 16 }}>
|
||||
<path d="M12 3.5c-4.7 0-8.5 3.8-8.5 8.5s3.8 8.5 8.5 8.5c1 0 1.7-.8 1.7-1.7 0-.45-.17-.86-.46-1.16-.29-.31-.46-.72-.46-1.17 0-.94.76-1.7 1.7-1.7h2c2.6 0 4.7-2.1 4.7-4.7 0-4.15-4.1-7.55-9.19-7.55Z" />
|
||||
<circle cx="7.7" cy="11" r="1.15" fill="currentColor" stroke="none" />
|
||||
<circle cx="10.2" cy="7.3" r="1.15" fill="currentColor" stroke="none" />
|
||||
<circle cx="14.4" cy="7.6" r="1.15" fill="currentColor" stroke="none" />
|
||||
<circle cx="16.6" cy="11.2" r="1.15" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,31 @@ interface ReleaseInfo {
|
||||
group: string | null;
|
||||
}
|
||||
|
||||
// Result of real container-header probing (aurora-curator-core's `probe` module, exposed
|
||||
// via the batched `probe_media_files` Tauri command) - resolution/duration/codecs read
|
||||
// straight from the file's own header, not guessed from its name. Complements (never
|
||||
// overrides) `ReleaseInfo` above, same as the Rename panel's Match step: a filename-tag
|
||||
// value wins whenever a file has both. `resolution`/`duration`/`fileSize` are
|
||||
// pre-formatted display strings computed on the Rust side.
|
||||
interface MediaProbe {
|
||||
fileSizeBytes: number;
|
||||
durationSeconds: number | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
frameRate: number | null;
|
||||
videoCodec: string | null;
|
||||
audioCodec: string | null;
|
||||
container: string | null;
|
||||
resolution: string | null;
|
||||
duration: string | null;
|
||||
fileSize: string;
|
||||
}
|
||||
|
||||
interface ProbeOutcome {
|
||||
path: string;
|
||||
probe: MediaProbe;
|
||||
}
|
||||
|
||||
// The naming-scheme-style expression List renders per file, reusing exactly the same
|
||||
// `render_expression` command (and therefore the same `{ }` syntax) the Rename panel's
|
||||
// live preview uses. `{name}`/`{ext}` always work; every other field only fills in once
|
||||
@@ -68,8 +93,15 @@ export default function ListPanel() {
|
||||
async function scan(targets: FileEntry[], base: Record<string, Record<string, string>>) {
|
||||
setScanning(true);
|
||||
try {
|
||||
// One batched round trip for real probing of every target, run alongside the
|
||||
// per-file filename guessing below rather than after it - same pattern as the
|
||||
// Rename panel's Match step.
|
||||
const probePromise = invoke<ProbeOutcome[]>("probe_media_files", {
|
||||
paths: targets.map((file) => file.path),
|
||||
}).catch(() => [] as ProbeOutcome[]);
|
||||
|
||||
const results = await Promise.all(
|
||||
targets.map(async (file): Promise<readonly [string, Record<string, string>]> => {
|
||||
targets.map(async (file): Promise<readonly [string, Record<string, string>, ReleaseInfo | null]> => {
|
||||
const releasePromise = invoke<ReleaseInfo | null>("guess_release_info", {
|
||||
filename: displayName(file),
|
||||
}).catch(() => null);
|
||||
@@ -120,19 +152,47 @@ export default function ListPanel() {
|
||||
}
|
||||
: {};
|
||||
|
||||
return [file.path, { ...base[file.path], ...extra, ...releaseBindings }] as const;
|
||||
return [file.path, { ...base[file.path], ...extra, ...releaseBindings }, release] as const;
|
||||
})
|
||||
);
|
||||
|
||||
const probes = new Map((await probePromise).map((outcome) => [outcome.path, outcome.probe] as const));
|
||||
|
||||
const next: Record<string, Record<string, string>> = {};
|
||||
let matchedCount = 0;
|
||||
for (const [path, b] of results) {
|
||||
next[path] = b;
|
||||
if (b.n) matchedCount += 1;
|
||||
let probedCount = 0;
|
||||
for (const [path, b, release] of results) {
|
||||
const probe = probes.get(path);
|
||||
// Real-probed fields: `resolution`/`video codec`/`audio codec` only fill in
|
||||
// when the filename tag above didn't already provide that field, but
|
||||
// `width`/`height`/`fps`/`duration`/`file size` have no filename-tag
|
||||
// equivalent at all, so those always apply whenever probing found them - same
|
||||
// override rule the Rename panel's Match step uses.
|
||||
const probeBindings: Record<string, string> = probe
|
||||
? {
|
||||
"file size": probe.fileSize,
|
||||
...(probe.width ? { width: String(probe.width) } : {}),
|
||||
...(probe.height ? { height: String(probe.height) } : {}),
|
||||
...(probe.frameRate ? { fps: String(Math.round(probe.frameRate * 100) / 100) } : {}),
|
||||
...(probe.duration ? { duration: probe.duration } : {}),
|
||||
...(!release?.resolution && probe.resolution
|
||||
? { vf: probe.resolution, resolution: probe.resolution }
|
||||
: {}),
|
||||
...(!release?.videoCodec && probe.videoCodec
|
||||
? { vc: probe.videoCodec, "video codec": probe.videoCodec }
|
||||
: {}),
|
||||
...(!release?.audioCodec && probe.audioCodec
|
||||
? { ac: probe.audioCodec, "audio codec": probe.audioCodec }
|
||||
: {}),
|
||||
}
|
||||
: {};
|
||||
if (probe?.container) probedCount += 1;
|
||||
next[path] = { ...b, ...probeBindings };
|
||||
if (next[path].n) matchedCount += 1;
|
||||
}
|
||||
setBindings(next);
|
||||
setStatus(
|
||||
`Scanned ${targets.length} file${targets.length === 1 ? "" : "s"} locally (${matchedCount} matched; no network calls).`
|
||||
`Scanned ${targets.length} file${targets.length === 1 ? "" : "s"} locally (${matchedCount} matched${probedCount > 0 ? `, ${probedCount} probed from file` : ""}; no network calls).`
|
||||
);
|
||||
} finally {
|
||||
setScanning(false);
|
||||
@@ -225,7 +285,11 @@ export default function ListPanel() {
|
||||
Same <code>{"{ }"}</code> syntax as the naming scheme. <code>{"{name}"}</code>/<code>{"{ext}"}</code> always work;{" "}
|
||||
<code>{"{show name}"}</code>/<code>{"{season number}"}</code>/<code>{"{episode number}"}</code>,{" "}
|
||||
<code>{"{movie name}"}</code>/<code>{"{year}"}</code>, and release tags fill in once a file is recognized -
|
||||
local matching only, no Fetch Data step.
|
||||
local matching only, no Fetch Data step. Files also get real container probing the same as the Rename
|
||||
panel's Match step: <code>{"{width}"}</code>/<code>{"{height}"}</code>/<code>{"{fps}"}</code>/
|
||||
<code>{"{duration}"}</code>/<code>{"{file size}"}</code> come straight from the file's own header, and a
|
||||
probed <code>{"{resolution}"}</code>/<code>{"{video codec}"}</code>/<code>{"{audio codec}"}</code> fills in
|
||||
whenever the filename itself didn't already carry that tag.
|
||||
</div>
|
||||
|
||||
<div className="list-results">
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
}
|
||||
|
||||
.list-row:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--tint-03);
|
||||
}
|
||||
|
||||
.list-row.selected {
|
||||
@@ -88,7 +88,7 @@
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-text);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: var(--tint-05);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
@@ -114,17 +114,17 @@
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--surface-deep);
|
||||
background: #172a31;
|
||||
background: var(--btn-bg);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-icon-button:hover {
|
||||
background: #203941;
|
||||
background: var(--btn-bg-hover);
|
||||
}
|
||||
|
||||
.list-icon-button:active {
|
||||
background: #28515a;
|
||||
background: var(--btn-bg-active);
|
||||
}
|
||||
|
||||
.list-icon-button:disabled {
|
||||
@@ -147,7 +147,7 @@
|
||||
flex-direction: column;
|
||||
min-width: 150px;
|
||||
padding: 4px;
|
||||
background: #16262d;
|
||||
background: var(--popup-bg);
|
||||
border: 1px solid var(--surface-deep);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
@@ -197,7 +197,7 @@
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--surface-deep);
|
||||
background: #172a31;
|
||||
background: var(--btn-bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
@@ -206,7 +206,7 @@
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background: #203941;
|
||||
background: var(--btn-bg-hover);
|
||||
}
|
||||
|
||||
.action-button.primary {
|
||||
@@ -217,7 +217,7 @@
|
||||
}
|
||||
|
||||
.action-button.primary:hover {
|
||||
background: #70e3bb;
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.format-editor {
|
||||
@@ -262,14 +262,20 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Deliberately NOT styled like .format-field-chip below (no tinted pill background) -
|
||||
this is plain inline documentation text, not a clickable control, and using the same
|
||||
pill treatment as the real chips made it look interactive when it wasn't. A user
|
||||
reported field references as "not clickable, you have to type it out" after clicking
|
||||
text like this instead of the real chips in .format-fields above; that only happened
|
||||
because the two looked so similar. Underline instead, which reads as "reference text"
|
||||
without implying a click target. */
|
||||
.format-hint code,
|
||||
.format-editor label code {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-family: ui-monospace, "SF Mono", monospace;
|
||||
font-size: 10.5px;
|
||||
color: var(--text);
|
||||
color: var(--muted-text);
|
||||
text-decoration: underline dotted var(--surface-deep);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.format-hint a {
|
||||
@@ -306,13 +312,26 @@
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.format-fields-group code {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
.format-field-chip {
|
||||
background: var(--tint-06);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-family: ui-monospace, "SF Mono", monospace;
|
||||
font-size: 10.5px;
|
||||
color: var(--aurora-green);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease, transform 0.06s ease;
|
||||
}
|
||||
|
||||
.format-field-chip:hover {
|
||||
background: var(--tint-16);
|
||||
}
|
||||
|
||||
.format-field-chip:active {
|
||||
background: var(--aurora-green);
|
||||
color: var(--surface);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
@@ -376,7 +395,7 @@
|
||||
}
|
||||
|
||||
.modal-plan-row:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--tint-03);
|
||||
}
|
||||
|
||||
.modal-plan-old {
|
||||
@@ -418,7 +437,7 @@
|
||||
}
|
||||
|
||||
.modal-plan-remove:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
background: var(--tint-06);
|
||||
color: #ff8a8a;
|
||||
}
|
||||
|
||||
@@ -545,7 +564,7 @@
|
||||
.history-batch {
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
background: var(--tint-02);
|
||||
}
|
||||
|
||||
.history-batch-header {
|
||||
@@ -570,7 +589,7 @@
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: var(--muted-text);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: var(--tint-05);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
@@ -599,7 +618,7 @@
|
||||
}
|
||||
|
||||
.preset-row:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--tint-03);
|
||||
}
|
||||
|
||||
.preset-slot-badge {
|
||||
@@ -610,7 +629,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
background: var(--tint-06);
|
||||
color: var(--aurora-green);
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
@@ -633,7 +652,7 @@
|
||||
}
|
||||
|
||||
.preset-expression:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
background: var(--tint-06);
|
||||
}
|
||||
|
||||
.preset-expression:disabled {
|
||||
|
||||
+452
-79
@@ -5,6 +5,7 @@ import {
|
||||
DownIcon,
|
||||
FetchIcon,
|
||||
GearIcon,
|
||||
HashIcon,
|
||||
HistoryIcon,
|
||||
KeyIcon,
|
||||
LoadIcon,
|
||||
@@ -91,6 +92,19 @@ interface ProbeOutcome {
|
||||
probe: MediaProbe;
|
||||
}
|
||||
|
||||
// Mirrors aurora-curator-core's `NamingHashOutcome`. `crc32`/`md5`/`sha1` are `null`
|
||||
// together with a non-null `error` when hashing that particular file failed (moved/
|
||||
// deleted between loading and hashing, a permissions problem, etc) - same per-file
|
||||
// failure shape as `MediaProbe`/`ProbeOutcome` above, so one bad file doesn't block the
|
||||
// rest of the batch.
|
||||
interface NamingHashOutcome {
|
||||
path: string;
|
||||
crc32: string | null;
|
||||
md5: string | null;
|
||||
sha1: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return n.toString().padStart(2, "0");
|
||||
}
|
||||
@@ -316,6 +330,16 @@ 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}";
|
||||
|
||||
// The separate naming scheme used for movie items (see `movieExpression` state below
|
||||
// for why TV and movies need their own independent scheme rather than sharing one):
|
||||
// unlike the TV scheme above, this one has no multi-stage "showcase the next field"
|
||||
// onboarding chain, because there's no comparably rich vocabulary to stage - a movie
|
||||
// item only ever has a title and a year (`{movie name}`/`{year}`), both of which are
|
||||
// already available right after local Match, and Fetch Data only refines them rather
|
||||
// than adding a genuinely new field the way it adds `{episode name}` for TV. So this
|
||||
// is just always the useful pattern from the start, not a milestone in a chain.
|
||||
const MOVIE_SAMPLE_EXPRESSION = "{movie name} ({year})";
|
||||
|
||||
// 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<
|
||||
@@ -452,6 +476,20 @@ function LoadMenuButton({ onFiles, onFolder }: { onFiles: () => void; onFolder:
|
||||
|
||||
export default function RenamePanel() {
|
||||
const [expression, setExpression] = useState(SAMPLE_EXPRESSION);
|
||||
// Whether the one-time "restore the last-saved naming scheme" load (see the effect
|
||||
// below) has finished - gates the debounced save effect so it can never fire before
|
||||
// the load does and stomp a saved scheme with the SAMPLE_EXPRESSION default.
|
||||
const [schemeLoaded, setSchemeLoaded] = useState(false);
|
||||
// The separate naming scheme for movie items - see MOVIE_SAMPLE_EXPRESSION's doc
|
||||
// comment above for why movies get their own independent scheme instead of sharing
|
||||
// `expression`. Persisted and restored the same way as the TV scheme, just to its
|
||||
// own settings field (`last_movie_naming_scheme`) so switching between the two
|
||||
// never overwrites the other.
|
||||
const [movieExpression, setMovieExpression] = useState(MOVIE_SAMPLE_EXPRESSION);
|
||||
const [movieSchemeLoaded, setMovieSchemeLoaded] = useState(false);
|
||||
// Which of the two schemes the Naming Scheme editor is currently showing/editing.
|
||||
// Purely a UI selection, not persisted - the editor always opens back on "tv".
|
||||
const [editingKind, setEditingKind] = useState<"tv" | "movie">("tv");
|
||||
const [editingFormat, setEditingFormat] = useState(false);
|
||||
const [items, setItems] = useState<Item[]>(SAMPLE_ITEMS);
|
||||
const [selected, setSelected] = useState<string | null>(SAMPLE_ITEMS[0].id);
|
||||
@@ -489,6 +527,17 @@ export default function RenamePanel() {
|
||||
const [omdbKeyInput, setOmdbKeyInput] = useState("");
|
||||
const [savingSettings, setSavingSettings] = useState(false);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
// Busy state for the combined Match step (see `runMatchAndFetch`) - covers both the
|
||||
// local scan and, when it runs, the automatic Fetch Data step right after, so the
|
||||
// Match button stays disabled for the whole combined operation rather than just the
|
||||
// first half of it.
|
||||
const [matching, setMatching] = useState(false);
|
||||
// Busy state for "Hash Files" (see `runHashFiles`) - deliberately its own state,
|
||||
// separate from `matching`/`fetching`, since hashing a whole file is much slower
|
||||
// than either of those (a full read of potentially multi-gigabyte video files,
|
||||
// rather than a small filename/header/network round trip) and isn't run
|
||||
// automatically as part of Match - the user opts into it explicitly.
|
||||
const [hashing, setHashing] = 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);
|
||||
@@ -543,14 +592,86 @@ export default function RenamePanel() {
|
||||
expressionRef.current = expression;
|
||||
}, [expression]);
|
||||
|
||||
// Set true when a real persisted scheme is restored from disk below. The three
|
||||
// "showcase the next field" auto-upgrade checkpoints further down (addRealFiles,
|
||||
// runMatching, runFetch) each compare expressionRef.current against an exact
|
||||
// milestone-constant string to infer "the user hasn't customized past this point
|
||||
// yet" - a heuristic that only holds for a fresh in-memory session. A restored
|
||||
// scheme can legitimately equal one of those constants (e.g. a user whose saved
|
||||
// scheme IS the sample/fetched expression), and without this guard the very next
|
||||
// checkpoint would silently stomp their correctly-restored scheme back to a
|
||||
// "simpler" milestone the moment files are loaded/matched/fetched. Once a real
|
||||
// value has been restored, none of the onboarding auto-upgrades should ever fire
|
||||
// again for this session, regardless of what the restored text happens to equal.
|
||||
const restoredCustomSchemeRef = useRef(false);
|
||||
|
||||
// Restores the naming-scheme editor's text from the last time the app was open (see
|
||||
// `src-tauri/src/settings.rs`'s `last_naming_scheme`) - without this, the editor
|
||||
// always reset to SAMPLE_EXPRESSION on every launch, silently discarding whatever
|
||||
// custom scheme the user had built. A missing/blank saved value (first run, or
|
||||
// nothing ever customized) leaves the built-in sample expression in place.
|
||||
// `schemeLoaded` only flips to true once this settles, so the save effect below can
|
||||
// never fire first and overwrite a saved scheme with the not-yet-replaced default.
|
||||
useEffect(() => {
|
||||
invoke<string | null>("get_last_naming_scheme")
|
||||
.then((saved) => {
|
||||
if (saved && saved.trim()) {
|
||||
setExpression(saved);
|
||||
restoredCustomSchemeRef.current = true;
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setSchemeLoaded(true));
|
||||
}, []);
|
||||
|
||||
// Persists the naming-scheme editor's text as the user edits it, debounced so a
|
||||
// fast typist doesn't hit disk on every keystroke. Deliberately separate from the
|
||||
// explicit 1-9 preset slots (`presets.rs`) - this tracks whatever's live in the
|
||||
// editor right now, not a named save.
|
||||
useEffect(() => {
|
||||
if (!schemeLoaded) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
invoke("save_last_naming_scheme", { expression }).catch(() => {});
|
||||
}, 600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [schemeLoaded, expression]);
|
||||
|
||||
// Restores/persists the movie naming scheme the same way as the TV one just above,
|
||||
// to its own separate settings field. No restoredCustomSchemeRef equivalent is
|
||||
// needed here - unlike the TV scheme, this one has no auto-upgrade checkpoints for
|
||||
// a restored value to collide with (see MOVIE_SAMPLE_EXPRESSION's doc comment).
|
||||
useEffect(() => {
|
||||
invoke<string | null>("get_last_movie_naming_scheme")
|
||||
.then((saved) => {
|
||||
if (saved && saved.trim()) setMovieExpression(saved);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setMovieSchemeLoaded(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!movieSchemeLoaded) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
invoke("save_last_movie_naming_scheme", { expression: movieExpression }).catch(() => {});
|
||||
}, 600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [movieSchemeLoaded, movieExpression]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
Promise.all(
|
||||
items.map(async (item) => {
|
||||
if (!item.bindings) return [item.id, null] as const;
|
||||
// Movie items render against their own separate scheme - see
|
||||
// MOVIE_SAMPLE_EXPRESSION's doc comment for why: the TV scheme's fields
|
||||
// (show name/season number/episode number/episode name) are never set on a
|
||||
// movie item, so rendering a movie against the TV scheme leaves nothing but
|
||||
// the scheme's own literal text (e.g. "{show name} - {season number}x
|
||||
// {episode number} - {episode name}" renders as the bare "- x -").
|
||||
const activeExpr = item.mediaKind === "movie" ? movieExpression : expression;
|
||||
const rendered = await invoke<string>("render_expression", {
|
||||
expression,
|
||||
expression: activeExpr,
|
||||
bindings: item.bindings,
|
||||
});
|
||||
return [item.id, `${rendered}${item.ext ? `.${item.ext}` : ""}`] as const;
|
||||
@@ -573,7 +694,7 @@ export default function RenamePanel() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [expression, items]);
|
||||
}, [expression, movieExpression, items]);
|
||||
|
||||
const notImplemented = (feature: string) => {
|
||||
setStatus(`${feature} isn't implemented yet — see the porting roadmap in the README.`);
|
||||
@@ -585,8 +706,9 @@ export default function RenamePanel() {
|
||||
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) {
|
||||
// and only if the user hasn't already customized it (including a scheme restored
|
||||
// from a previous session - see restoredCustomSchemeRef above).
|
||||
if (expressionRef.current === SAMPLE_EXPRESSION && !restoredCustomSchemeRef.current) {
|
||||
setExpression(REAL_FILE_EXPRESSION);
|
||||
}
|
||||
|
||||
@@ -654,13 +776,23 @@ export default function RenamePanel() {
|
||||
// batched `probe_media_files` command) for resolution/duration/codecs read straight
|
||||
// from its container header - see the bindings merge below for how a probed value
|
||||
// and a filename-tag value combine.
|
||||
const runMatching = async () => {
|
||||
// Returns the freshly-matched items (so a caller chaining straight into `runFetch`
|
||||
// - see `runMatchAndFetch` below - can hand it the real, just-computed array instead
|
||||
// of reading `items` state, which wouldn't reflect this match yet: `setItems` here
|
||||
// queues a re-render, it doesn't update the `items` this closure sees immediately).
|
||||
// Returns `undefined` when there was nothing to match (nothing loaded yet).
|
||||
// `chained`: set by `runMatchAndFetch` to skip scheduling this function's own
|
||||
// status-clearing timeout on the success path - when chained, `runFetch` runs
|
||||
// immediately after and takes over the status line with its own timer, and letting
|
||||
// both timers race risks the earlier one blanking the later one's message first
|
||||
// (see `runMatchAndFetch`'s doc comment for the concrete scenario).
|
||||
const runMatching = async (opts?: { chained?: boolean }): Promise<Item[] | undefined> => {
|
||||
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;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// One batched round trip for real probing of every target, run alongside the
|
||||
@@ -712,9 +844,13 @@ export default function RenamePanel() {
|
||||
const tagged = results.filter(([, , release]) => release !== null).length;
|
||||
const probed = [...probes.values()].filter((probe) => probe.container !== null).length;
|
||||
|
||||
setItems((prev) =>
|
||||
prev.map((item) => {
|
||||
const guess = guesses.get(item.id);
|
||||
// A plain `items.map(...)` rather than the usual `setItems((prev) => prev.map(...))`
|
||||
// functional form - deliberately, so the resulting array is available synchronously
|
||||
// right here to both `setItems` and, for the chained-from-Match case, `return` to
|
||||
// the caller (see this function's doc comment above). Safe because nothing else in
|
||||
// this function queues a competing `items` update in between.
|
||||
const nextItems = items.map((item): Item => {
|
||||
const guess = guesses.get(item.id);
|
||||
const release = releaseGuesses.get(item.id);
|
||||
const probe = probes.get(item.id);
|
||||
|
||||
@@ -801,14 +937,16 @@ export default function RenamePanel() {
|
||||
...releaseBindings,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
});
|
||||
setItems(nextItems);
|
||||
|
||||
// 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) {
|
||||
// default (including a scheme restored from a previous session - see
|
||||
// restoredCustomSchemeRef above). 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 && !restoredCustomSchemeRef.current) {
|
||||
setExpression(MATCHED_EXPRESSION);
|
||||
}
|
||||
|
||||
@@ -817,8 +955,11 @@ export default function RenamePanel() {
|
||||
? `Matched ${matched} of ${targets.length} file${targets.length === 1 ? "" : "s"} (${matchedTv} TV, ${matchedMovie} movie${matchedMovie === 1 ? "" : "s"}${tagged > 0 ? `, ${tagged} with release tags` : ""}${probed > 0 ? `, ${probed} probed from file` : ""}; local only, no network calls).`
|
||||
: "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);
|
||||
if (!opts?.chained) {
|
||||
window.clearTimeout((runMatching as any)._t);
|
||||
(runMatching as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
||||
}
|
||||
return nextItems;
|
||||
};
|
||||
|
||||
// Step 1 of Rename: build the plan (old path -> new filename) from the live preview
|
||||
@@ -1123,18 +1264,33 @@ export default function RenamePanel() {
|
||||
// OMDb) - the two settings are independent, so a batch mixing TV and movies can even
|
||||
// use different sources for each half. Both run via separate Tauri commands,
|
||||
// concurrently, and merge back into a single set of counts/status message.
|
||||
const runFetch = async () => {
|
||||
const tvTargets = items.filter(
|
||||
//
|
||||
// `itemsOverride`: read targets from this array instead of `items` state - used by
|
||||
// `runMatchAndFetch` to hand this the just-computed match results directly, since
|
||||
// `items` state wouldn't reflect them yet (see `runMatching`'s doc comment).
|
||||
// `silentIfEmpty`: skip the "Run Match first" message when there's nothing to fetch,
|
||||
// instead of complaining - used the same way, for a chained call right after a Match
|
||||
// that simply found nothing recognizable; `runMatching`'s own message already covers
|
||||
// that. `chained`: skip auto-opening the Settings modal on a missing credential -
|
||||
// still explains why in the status line, but doesn't interrupt every single Match
|
||||
// click with a popup for someone who hasn't set up Fetch Data (or doesn't want to).
|
||||
// Returns whether this call took ownership of the status line/its clear timer (false
|
||||
// only for the `silentIfEmpty` bypass) - `runMatchAndFetch` uses that to know whether
|
||||
// it still needs to schedule a clear itself.
|
||||
const runFetch = async (opts?: { itemsOverride?: Item[]; silentIfEmpty?: boolean; chained?: boolean }): Promise<boolean> => {
|
||||
const source = opts?.itemsOverride ?? items;
|
||||
const tvTargets = source.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 movieTargets = source.filter((i) => !i.isSample && i.mediaKind === "movie" && i.bindings?.n);
|
||||
const targetCount = tvTargets.length + movieTargets.length;
|
||||
|
||||
if (targetCount === 0) {
|
||||
if (opts?.silentIfEmpty) return false;
|
||||
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;
|
||||
return true;
|
||||
}
|
||||
|
||||
// TVmaze needs no credential at all, so TMDB/OMDb are only actually required when
|
||||
@@ -1150,10 +1306,10 @@ export default function RenamePanel() {
|
||||
needsOmdbKey && hasOmdbKey === false ? "a free OMDb API key" : null,
|
||||
].filter((m): m is string => m !== null);
|
||||
setStatus(`Add ${missing.join(" and ")} in Settings first, then Fetch Data will work.`);
|
||||
setSettingsOpen(true);
|
||||
if (!opts?.chained) setSettingsOpen(true);
|
||||
window.clearTimeout((runFetch as any)._t);
|
||||
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
setFetching(true);
|
||||
@@ -1251,8 +1407,10 @@ export default function RenamePanel() {
|
||||
);
|
||||
|
||||
// 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) {
|
||||
// hasn't already customized the naming scheme away from the Match-set default
|
||||
// (including a scheme restored from a previous session - see
|
||||
// restoredCustomSchemeRef above).
|
||||
if (expressionRef.current === MATCHED_EXPRESSION && found > 0 && !restoredCustomSchemeRef.current) {
|
||||
setExpression(FETCHED_EXPRESSION);
|
||||
}
|
||||
|
||||
@@ -1267,6 +1425,97 @@ export default function RenamePanel() {
|
||||
window.clearTimeout((runFetch as any)._t);
|
||||
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 5000);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// "Match" now does the whole job in one click: scan filenames locally (as before),
|
||||
// then - if that found anything recognizable and either credential-free source
|
||||
// (TVmaze) is in use or the needed credential is already on file - immediately fetch
|
||||
// real online metadata for it too, exactly as if the user had clicked Match and then
|
||||
// Fetch Data separately. Requested directly: "when you click Match, I would prefer
|
||||
// it to do what the lookup does... just one less step needed." The standalone
|
||||
// "Fetch Data" button alongside the New Names list is untouched and still works on
|
||||
// its own - useful for re-fetching after changing the metadata source, fixing a
|
||||
// guessed name by hand, or adding a credential after the fact.
|
||||
const runMatchAndFetch = async () => {
|
||||
setMatching(true);
|
||||
try {
|
||||
const nextItems = await runMatching({ chained: true });
|
||||
if (!nextItems) return; // nothing loaded - runMatching already showed its own message
|
||||
|
||||
const fetchHandledStatus = await runFetch({ itemsOverride: nextItems, silentIfEmpty: true, chained: true });
|
||||
if (!fetchHandledStatus) {
|
||||
// Match found nothing to look up online (no TV/movie recognized at all) - its
|
||||
// own message is still on screen, and since it ran chained it skipped
|
||||
// scheduling its own clear timer (that's normally whichever step runs last).
|
||||
// This was the last step here, so it takes over that job.
|
||||
window.clearTimeout((runMatchAndFetch as any)._t);
|
||||
(runMatchAndFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
||||
}
|
||||
} finally {
|
||||
setMatching(false);
|
||||
}
|
||||
};
|
||||
|
||||
// "Hash Files": computes real CRC32/MD5/SHA-1 checksums for every loaded file and
|
||||
// fills in the {crc32}/{md5}/{sha1} naming-scheme fields - reuses the SFV panel's
|
||||
// own hasher (`aurora_curator_core::sfv::compute_all_checksums`, via the
|
||||
// `compute_naming_hashes` command) rather than a separate implementation. Unlike
|
||||
// Match's other local steps, this isn't wired into Match automatically: hashing
|
||||
// reads a file's *entire* contents (potentially many gigabytes for video), unlike
|
||||
// filename parsing or container-header probing, which only touch a small header -
|
||||
// running it on every Match click for every loaded file, whether or not the naming
|
||||
// scheme even uses a hash field, would make Match unpredictably slow. So this is its
|
||||
// own explicit action, independent of media identification (a hash needs nothing
|
||||
// but a real file on disk - no season/episode guess, no online lookup).
|
||||
const runHashFiles = async () => {
|
||||
const targets = items.filter((i) => !i.isSample);
|
||||
if (targets.length === 0) {
|
||||
setStatus("Load some files first, then Hash Files will compute their CRC32/MD5/SHA-1 checksums.");
|
||||
window.clearTimeout((runHashFiles as any)._t);
|
||||
(runHashFiles as any)._t = window.setTimeout(() => setStatus(null), 3200);
|
||||
return;
|
||||
}
|
||||
|
||||
setHashing(true);
|
||||
try {
|
||||
const results = await invoke<NamingHashOutcome[]>("compute_naming_hashes", {
|
||||
paths: targets.map((item) => item.id),
|
||||
});
|
||||
const byPath = new Map(results.map((r) => [r.path, r] as const));
|
||||
// Count directly from `results`, not inside the `setItems` updater below - same
|
||||
// rule as runMatching/runFetch/confirmRename above (React doesn't run a
|
||||
// functional updater synchronously, so a counter mutated in there wouldn't be
|
||||
// ready yet for the status message right after).
|
||||
const hashed = results.filter((r) => r.error === null).length;
|
||||
const failed = results.length - hashed;
|
||||
|
||||
setItems((prev) =>
|
||||
prev.map((item) => {
|
||||
const result = byPath.get(item.id);
|
||||
if (!result || result.error !== null || !result.crc32 || !result.md5 || !result.sha1) return item;
|
||||
return {
|
||||
...item,
|
||||
bindings: {
|
||||
...item.bindings,
|
||||
crc32: result.crc32,
|
||||
md5: result.md5,
|
||||
sha1: result.sha1,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const parts = [`Hashed ${hashed} of ${targets.length} file${targets.length === 1 ? "" : "s"}`];
|
||||
if (failed > 0) parts.push(`${failed} failed`);
|
||||
setStatus(parts.join(", ") + ".");
|
||||
} catch (err) {
|
||||
setStatus(`Hashing failed: ${err}`);
|
||||
} finally {
|
||||
setHashing(false);
|
||||
window.clearTimeout((runHashFiles as any)._t);
|
||||
(runHashFiles as any)._t = window.setTimeout(() => setStatus(null), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const removeSelected = () => {
|
||||
@@ -1308,6 +1557,13 @@ export default function RenamePanel() {
|
||||
});
|
||||
};
|
||||
|
||||
// Whichever of the two schemes the editor is currently showing - presets, the field
|
||||
// chips, and typing into the box all act on this one rather than always the TV
|
||||
// scheme, so switching the "TV Shows"/"Movies" toggle changes what a 1-9 hotkey or a
|
||||
// clicked field chip actually affects.
|
||||
const activeExpression = editingKind === "movie" ? movieExpression : expression;
|
||||
const setActiveExpression = editingKind === "movie" ? setMovieExpression : setExpression;
|
||||
|
||||
// Presets panel + 1-9 hotkeys. Applying a preset just swaps the naming-scheme
|
||||
// expression (same as typing a new one into the format editor) - it doesn't touch
|
||||
// `items` at all, so the live preview recomputes for whatever's already loaded.
|
||||
@@ -1324,7 +1580,7 @@ export default function RenamePanel() {
|
||||
const applyPreset = (slot: string) => {
|
||||
const value = presets?.[slot];
|
||||
if (!value) return;
|
||||
setExpression(value);
|
||||
setActiveExpression(value);
|
||||
setStatus(`Applied preset ${slot}.`);
|
||||
window.clearTimeout((applyPreset as any)._t);
|
||||
(applyPreset as any)._t = window.setTimeout(() => setStatus(null), 2500);
|
||||
@@ -1333,8 +1589,8 @@ export default function RenamePanel() {
|
||||
const saveCurrentAsPreset = async (slot: string) => {
|
||||
setPresetsBusySlot(slot);
|
||||
try {
|
||||
await invoke("save_preset", { slot, expression });
|
||||
setPresets((prev) => ({ ...(prev ?? {}), [slot]: expression }));
|
||||
await invoke("save_preset", { slot, expression: activeExpression });
|
||||
setPresets((prev) => ({ ...(prev ?? {}), [slot]: activeExpression }));
|
||||
} catch (err) {
|
||||
setStatus(`Couldn't save preset ${slot}: ${err}`);
|
||||
} finally {
|
||||
@@ -1405,6 +1661,47 @@ export default function RenamePanel() {
|
||||
);
|
||||
};
|
||||
|
||||
// So clicking a field reference below (e.g. "{show name}") can insert it into the
|
||||
// naming-scheme input at the caret instead of the user typing the exact text out by
|
||||
// hand every time.
|
||||
const expressionInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const insertField = (token: string) => {
|
||||
const el = expressionInputRef.current;
|
||||
if (!el) {
|
||||
// No live input to read a caret position from (shouldn't normally happen, since
|
||||
// this is only reachable while the editor is open) - fall back to appending.
|
||||
setActiveExpression((prev) => prev + token);
|
||||
return;
|
||||
}
|
||||
const start = el.selectionStart ?? el.value.length;
|
||||
const end = el.selectionEnd ?? el.value.length;
|
||||
const next = el.value.slice(0, start) + token + el.value.slice(end);
|
||||
setActiveExpression(next);
|
||||
// Restore focus and put the caret right after what was just inserted, so clicking
|
||||
// several fields in a row inserts them left-to-right rather than all piling up at
|
||||
// the same spot. Deferred a tick so this runs after React re-renders the input
|
||||
// with the new value.
|
||||
const caret = start + token.length;
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
el.setSelectionRange(caret, caret);
|
||||
});
|
||||
};
|
||||
|
||||
// One clickable field-reference button, styled like the plain `<code>` tags this
|
||||
// replaced (see RenamePanel.css's `.format-field-chip`) but wired to `insertField`.
|
||||
const FieldChip = ({ token }: { token: string }) => (
|
||||
<button
|
||||
type="button"
|
||||
className="format-field-chip"
|
||||
title={`Insert ${token} at the cursor`}
|
||||
onClick={() => insertField(token)}
|
||||
>
|
||||
{token}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rename-panel">
|
||||
<div className="rename-lists">
|
||||
@@ -1428,8 +1725,13 @@ export default function RenamePanel() {
|
||||
/>
|
||||
|
||||
<div className="rename-actions">
|
||||
<button className="action-button" onClick={runMatching}>
|
||||
Match
|
||||
<button
|
||||
className="action-button"
|
||||
onClick={() => void runMatchAndFetch()}
|
||||
disabled={matching || fetching || hashing}
|
||||
title="Scans filenames locally, then automatically fetches real online metadata for anything recognized - no separate Fetch Data click needed."
|
||||
>
|
||||
{matching ? "Matching…" : "Match"}
|
||||
</button>
|
||||
<button className="action-button primary" onClick={prepareRename}>
|
||||
Rename
|
||||
@@ -1447,7 +1749,18 @@ export default function RenamePanel() {
|
||||
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => moveSelected("up")} />
|
||||
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
|
||||
<span className="list-button-gap" />
|
||||
<ListButton icon={<FetchIcon />} label="Fetch Data" onClick={runFetch} disabled={fetching} />
|
||||
<ListButton
|
||||
icon={<FetchIcon />}
|
||||
label="Fetch Data (Match already runs this automatically - use this to re-fetch)"
|
||||
onClick={() => void runFetch()}
|
||||
disabled={fetching || matching || hashing}
|
||||
/>
|
||||
<ListButton
|
||||
icon={<HashIcon />}
|
||||
label="Hash Files (computes real CRC32/MD5/SHA-1 for {crc32}/{md5}/{sha1} - reads the whole file, can be slow)"
|
||||
onClick={() => void runHashFiles()}
|
||||
disabled={hashing || matching || fetching}
|
||||
/>
|
||||
<ListButton icon={<KeyIcon />} label="Fetch Data Settings" onClick={openSettings} />
|
||||
<ListButton icon={<GearIcon />} label="Naming Scheme" onClick={() => setEditingFormat((v) => !v)} />
|
||||
</>
|
||||
@@ -1457,16 +1770,37 @@ export default function RenamePanel() {
|
||||
|
||||
{editingFormat && (
|
||||
<div className="format-editor">
|
||||
<label htmlFor="expression">Naming scheme</label>
|
||||
<span className="modal-field-label">Naming scheme for</span>
|
||||
<div className="action-segmented" role="radiogroup" aria-label="Which kind of item this naming scheme applies to">
|
||||
<button
|
||||
type="button"
|
||||
className={`action-segmented-option${editingKind === "tv" ? " selected" : ""}`}
|
||||
onClick={() => setEditingKind("tv")}
|
||||
>
|
||||
TV Shows
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`action-segmented-option${editingKind === "movie" ? " selected" : ""}`}
|
||||
onClick={() => setEditingKind("movie")}
|
||||
>
|
||||
Movies
|
||||
</button>
|
||||
</div>
|
||||
<label htmlFor="expression">
|
||||
{editingKind === "movie" ? "Movie naming scheme" : "TV naming scheme"}
|
||||
</label>
|
||||
<input
|
||||
id="expression"
|
||||
value={expression}
|
||||
ref={expressionInputRef}
|
||||
value={activeExpression}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setExpression(e.target.value)}
|
||||
onChange={(e) => setActiveExpression(e.target.value)}
|
||||
/>
|
||||
{computeError && <p className="format-error">{computeError}</p>}
|
||||
<p className="format-hint">
|
||||
This field is fully yours to edit — type any combination of literal text and{" "}
|
||||
TV episodes and movies each get their own naming scheme, saved separately — switch the toggle above to
|
||||
edit the other one. This field is fully yours to edit — type any combination of literal text and{" "}
|
||||
<code>{"{field}"}</code> 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 <code>{"{field name}"}</code>, spaces and all — no coding
|
||||
@@ -1474,56 +1808,89 @@ export default function RenamePanel() {
|
||||
<a href="https://rhai.rs/" target="_blank" rel="noreferrer">
|
||||
Rhai
|
||||
</a>{" "}
|
||||
script for anyone who wants it (e.g. <code>{"{show name.to_upper()}"}</code>).
|
||||
script for anyone who wants it (e.g. <code>{"{show name.to_upper()}"}</code>). Click any field below to
|
||||
insert it at the cursor instead of typing it out.
|
||||
</p>
|
||||
<div className="format-fields">
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">Always available</span>
|
||||
<code>{"{name}"}</code>
|
||||
<code>{"{ext}"}</code>
|
||||
</div>
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
TV, after <strong>Match</strong> (local, offline)
|
||||
</span>
|
||||
<code>{"{show name}"}</code>
|
||||
<code>{"{season number}"}</code>
|
||||
<code>{"{episode number}"}</code>
|
||||
</div>
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
TV, after <strong>Fetch Data</strong> (real TMDB metadata)
|
||||
</span>
|
||||
<code>{"{episode name}"}</code>
|
||||
<code>{"{airdate}"}</code>
|
||||
</div>
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
Movies, after <strong>Match</strong> or <strong>Fetch Data</strong>
|
||||
</span>
|
||||
<code>{"{movie name}"}</code>
|
||||
<code>{"{year}"}</code>
|
||||
<code>{"{airdate}"}</code>
|
||||
<FieldChip token="{name}" />
|
||||
<FieldChip token="{ext}" />
|
||||
</div>
|
||||
{editingKind === "tv" && (
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
TV, after <strong>Match</strong> (local, offline)
|
||||
</span>
|
||||
<FieldChip token="{show name}" />
|
||||
<FieldChip token="{season number}" />
|
||||
<FieldChip token="{episode number}" />
|
||||
</div>
|
||||
)}
|
||||
{editingKind === "tv" && (
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
TV, after <strong>Fetch Data</strong> (real TMDB metadata)
|
||||
</span>
|
||||
<FieldChip token="{episode name}" />
|
||||
<FieldChip token="{airdate}" />
|
||||
</div>
|
||||
)}
|
||||
{editingKind === "movie" && (
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
Movies, after <strong>Match</strong> or <strong>Fetch Data</strong>
|
||||
</span>
|
||||
<FieldChip token="{movie name}" />
|
||||
<FieldChip token="{year}" />
|
||||
<FieldChip token="{airdate}" />
|
||||
</div>
|
||||
)}
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
Release tags, after <strong>Match</strong> (TV or movies, local, offline)
|
||||
</span>
|
||||
<code>{"{resolution}"}</code>
|
||||
<code>{"{source}"}</code>
|
||||
<code>{"{video codec}"}</code>
|
||||
<code>{"{audio codec}"}</code>
|
||||
<code>{"{release group}"}</code>
|
||||
<FieldChip token="{resolution}" />
|
||||
<FieldChip token="{source}" />
|
||||
<FieldChip token="{video codec}" />
|
||||
<FieldChip token="{audio codec}" />
|
||||
<FieldChip token="{release group}" />
|
||||
</div>
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
Real file probing, after <strong>Match</strong> (reads the actual file, local, offline)
|
||||
</span>
|
||||
<code>{"{width}"}</code>
|
||||
<code>{"{height}"}</code>
|
||||
<code>{"{fps}"}</code>
|
||||
<code>{"{duration}"}</code>
|
||||
<code>{"{file size}"}</code>
|
||||
<FieldChip token="{width}" />
|
||||
<FieldChip token="{height}" />
|
||||
<FieldChip token="{fps}" />
|
||||
<FieldChip token="{duration}" />
|
||||
<FieldChip token="{file size}" />
|
||||
</div>
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
File hashing, after <strong>Hash Files</strong> (reads the whole file - can be slow)
|
||||
</span>
|
||||
<FieldChip token="{crc32}" />
|
||||
<FieldChip token="{md5}" />
|
||||
<FieldChip token="{sha1}" />
|
||||
</div>
|
||||
{/* Short, FileBot-style aliases for several of the fields above (e.g. {n} is the
|
||||
same value as {show name}/{movie name}) - these previously only appeared as
|
||||
plain, non-clickable <code> text in the paragraph below, which looks almost
|
||||
identical to a real field chip (same tinted-pill styling) and led a user to
|
||||
report field references as "not clickable" when what they'd actually clicked
|
||||
was this inert prose text. Giving the short names their own real chip group
|
||||
fixes that directly instead of just re-explaining it in the hint text. */}
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">Short names (same fields, FileBot-style)</span>
|
||||
<FieldChip token="{n}" />
|
||||
<FieldChip token="{s00e00}" />
|
||||
<FieldChip token="{t}" />
|
||||
<FieldChip token="{y}" />
|
||||
<FieldChip token="{vf}" />
|
||||
<FieldChip token="{vc}" />
|
||||
<FieldChip token="{ac}" />
|
||||
<FieldChip token="{group}" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="format-hint">
|
||||
@@ -1535,15 +1902,21 @@ export default function RenamePanel() {
|
||||
<code>{"{video codec}"}</code>, and <code>{"{audio codec}"}</code> still fill in from the real file when
|
||||
the filename doesn't carry that tag, and <code>{"{width}"}</code>/<code>{"{height}"}</code>/
|
||||
<code>{"{fps}"}</code>/<code>{"{duration}"}</code>/<code>{"{file size}"}</code> are only ever available
|
||||
this way. Fetch Data resolves the
|
||||
season/episode or movie guess against real online metadata for the real title, episode title, and
|
||||
air/release date. TV episodes can come from TMDB, TVmaze, or OMDb, and movies from TMDB or OMDb — pick
|
||||
each independently in <strong>Fetch Data Settings</strong> (TVmaze is free and needs no credential at
|
||||
all; TMDB and OMDb each need their own free API credential set there first). A batch can
|
||||
freely mix TV and movie files, even against different sources — each file only fills in the fields that apply to it. FileBot's own short
|
||||
field names (<code>{"{n}"}</code>, <code>{"{s00e00}"}</code>, <code>{"{t}"}</code>, <code>{"{y}"}</code>,
|
||||
<code>{"{vf}"}</code>, <code>{"{vc}"}</code>, <code>{"{ac}"}</code>, <code>{"{group}"}</code>, etc.) still
|
||||
work too, side by side with the names above.
|
||||
this way. <strong>Hash Files</strong> computes real <code>{"{crc32}"}</code>/<code>{"{md5}"}</code>/
|
||||
<code>{"{sha1}"}</code> checksums the same way the SFV panel does — deliberately not automatic like the
|
||||
rest of Match, since hashing reads a file's entire contents rather than just a small header, so it's its
|
||||
own separate, explicit step. Clicking <strong>Match</strong> then automatically runs Fetch Data right
|
||||
after, for anything it
|
||||
recognized — no separate click needed. Fetch Data itself resolves the season/episode or movie guess
|
||||
against real online metadata for the real title, episode title, and air/release date. TV episodes can
|
||||
come from TMDB, TVmaze, or OMDb, and movies from TMDB or OMDb — pick each independently in{" "}
|
||||
<strong>Fetch Data Settings</strong> (TVmaze is free and needs no credential at all; TMDB and OMDb each
|
||||
need their own free API credential set there first). The separate Fetch Data button next to New Names
|
||||
still works on its own too, for re-fetching after changing the source or fixing a guessed name by hand. A
|
||||
batch can freely mix TV and movie files, even against different sources — each file only fills in the
|
||||
fields that apply to it. The short, FileBot-style field names in the last group above ({"{n}"},{" "}
|
||||
{"{s00e00}"}, and the rest) are just aliases for the same values as the human-readable names — use
|
||||
whichever you prefer, or mix both in one scheme.
|
||||
</p>
|
||||
<p className="format-hint">
|
||||
Use <code>{"/"}</code> in the scheme to organize files into subfolders as part of renaming, e.g.{" "}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
.theme-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
margin: 4px 0 4px;
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--surface-deep);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-card:hover {
|
||||
border-color: var(--dock-line);
|
||||
}
|
||||
|
||||
.theme-card.selected {
|
||||
border-color: var(--aurora-green);
|
||||
box-shadow: 0 0 0 1px var(--aurora-green);
|
||||
}
|
||||
|
||||
.theme-card:disabled {
|
||||
opacity: 0.75;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.theme-swatch {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--surface-deep);
|
||||
}
|
||||
|
||||
.theme-swatch-chip {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.theme-card-name {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.theme-card-check {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 10px;
|
||||
color: var(--aurora-green);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// App-wide Settings: currently just the color theme, opened from the gear-adjacent
|
||||
// palette icon in the sidebar footer (see Sidebar.tsx). Everything else the app
|
||||
// persists lives in its own panel-specific settings modal (Fetch Data Settings in
|
||||
// RenamePanel/EpisodesPanel, subtitle provider settings in SubtitlesPanel) - this is
|
||||
// the one truly app-wide setting so far, hence its own top-level entry point rather
|
||||
// than living inside one of those panels.
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import "./SettingsModal.css";
|
||||
|
||||
export type ThemeId = "aurora" | "white" | "black" | "ocean" | "sunset";
|
||||
|
||||
export const DEFAULT_THEME: ThemeId = "aurora";
|
||||
|
||||
interface ThemeDef {
|
||||
id: ThemeId;
|
||||
name: string;
|
||||
description: string;
|
||||
// Literal swatch colors (background, primary accent, secondary accent) matching
|
||||
// theme.css's own values for this theme - these can't be CSS variables, since the
|
||||
// whole point is to preview a theme that isn't the currently-active one.
|
||||
swatch: [string, string, string];
|
||||
}
|
||||
|
||||
const THEMES: ThemeDef[] = [
|
||||
{ id: "aurora", name: "Aurora", description: "The original dark teal & green look.", swatch: ["#08252b", "#55d7a9", "#52c7db"] },
|
||||
{ id: "white", name: "White", description: "White background, black text, colorful accents.", swatch: ["#ffffff", "#1f9d74", "#157e9c"] },
|
||||
{ id: "black", name: "Black", description: "True black background, white text, grayscale accents.", swatch: ["#000000", "#e5e5e5", "#bfbfbf"] },
|
||||
{ id: "ocean", name: "Ocean", description: "A cool blue-toned dark theme.", swatch: ["#071c2e", "#4fd0e8", "#5aa9ff"] },
|
||||
{ id: "sunset", name: "Sunset", description: "A warm amber-toned dark theme.", swatch: ["#2b1408", "#f0955a", "#f0b66b"] },
|
||||
];
|
||||
|
||||
function isThemeId(value: string): value is ThemeId {
|
||||
return THEMES.some((t) => t.id === value);
|
||||
}
|
||||
|
||||
/// Applies a theme id to the document immediately, so switching feels instant instead
|
||||
/// of waiting on a round trip through Rust. Shared by App.tsx's boot-time load and
|
||||
/// this modal's click handler, so both go through the exact same logic.
|
||||
export function applyTheme(theme: ThemeId) {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
}
|
||||
|
||||
export default function SettingsModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [current, setCurrent] = useState<ThemeId>(DEFAULT_THEME);
|
||||
const [saving, setSaving] = useState<ThemeId | null>(null);
|
||||
|
||||
// Keep this modal's own idea of "the active theme" in sync with whatever's really
|
||||
// applied - reads the live `data-theme` attribute rather than re-fetching from disk,
|
||||
// since App.tsx's boot-time effect (or a previous open of this same modal) already
|
||||
// applied it there.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const applied = document.documentElement.dataset.theme;
|
||||
setCurrent(applied && isThemeId(applied) ? applied : DEFAULT_THEME);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const pick = async (theme: ThemeId) => {
|
||||
if (theme === current) return;
|
||||
setSaving(theme);
|
||||
applyTheme(theme);
|
||||
setCurrent(theme);
|
||||
try {
|
||||
await invoke("save_theme", { theme });
|
||||
} catch {
|
||||
// The theme still applied for this session even if saving it failed - not
|
||||
// worth blocking or rolling back the visual change over a disk-write hiccup.
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-card modal-card-compact" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="modal-title">Settings</h2>
|
||||
<span className="modal-field-label">Theme</span>
|
||||
<p className="modal-subtitle">Pick a color theme for the whole app. Applies immediately and remembers your choice.</p>
|
||||
<div className="theme-grid">
|
||||
{THEMES.map((theme) => (
|
||||
<button
|
||||
key={theme.id}
|
||||
type="button"
|
||||
className={`theme-card${current === theme.id ? " selected" : ""}`}
|
||||
onClick={() => void pick(theme.id)}
|
||||
disabled={saving !== null}
|
||||
title={theme.description}
|
||||
>
|
||||
<span className="theme-swatch">
|
||||
{theme.swatch.map((color, i) => (
|
||||
<span key={i} className="theme-swatch-chip" style={{ background: color }} />
|
||||
))}
|
||||
</span>
|
||||
<span className="theme-card-name">{theme.name}</span>
|
||||
{current === theme.id && <span className="theme-card-check">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="action-button primary" onClick={onClose}>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -72,6 +72,25 @@
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.sidebar-settings-button {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: var(--muted-text);
|
||||
cursor: pointer;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.sidebar-settings-button:hover {
|
||||
color: var(--text);
|
||||
background: var(--tint-06);
|
||||
}
|
||||
|
||||
.sidebar-wordmark {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { EpisodesIcon, FilterIcon, ListIcon, RenameIcon, SfvIcon, SubtitlesIcon } from "./Icons";
|
||||
import { useState } from "react";
|
||||
import { EpisodesIcon, FilterIcon, ListIcon, PaletteIcon, RenameIcon, SfvIcon, SubtitlesIcon } from "./Icons";
|
||||
import SettingsModal from "./SettingsModal";
|
||||
|
||||
export interface PanelDef {
|
||||
id: string;
|
||||
@@ -25,6 +27,8 @@ export default function Sidebar({
|
||||
selected: string;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<nav className="sidebar">
|
||||
<ul className="sidebar-list">
|
||||
@@ -47,9 +51,18 @@ export default function Sidebar({
|
||||
</ul>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<button
|
||||
className="sidebar-settings-button"
|
||||
title="Settings (theme)"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
<PaletteIcon />
|
||||
</button>
|
||||
<span className="sidebar-wordmark">Aurora Curator</span>
|
||||
<span className="sidebar-version">v{APP_VERSION}</span>
|
||||
</div>
|
||||
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
+172
-4
@@ -1,7 +1,13 @@
|
||||
/* Aurora Curator's UI theme — ported 1:1 (hex values) from the original FB-Mod fork's
|
||||
AuroraTheme.java / HeaderPanel.java / MainFrame.java so the rewrite looks like a
|
||||
continuation of that design rather than a random restyle. (The app takes its name,
|
||||
"Aurora Curator", from this same Aurora theme.) */
|
||||
/* Aurora Curator's UI theme. The "aurora" theme below is ported 1:1 (hex values) from
|
||||
the original FB-Mod fork's AuroraTheme.java / HeaderPanel.java / MainFrame.java so
|
||||
the rewrite looks like a continuation of that design rather than a random restyle
|
||||
(the app takes its name, "Aurora Curator", from this same Aurora theme) - it stays
|
||||
the default. The other four theme blocks below (white/black/ocean/sunset) are Aurora
|
||||
Curator's own additions, picked via Settings (gear icon, sidebar footer) and applied
|
||||
as a `data-theme` attribute on <html>; every visible color in the app is meant to
|
||||
flow through one of these custom properties so switching theme is just switching
|
||||
which block of them is active - see the file's own module-doc-style notes on the
|
||||
less obvious variables below. */
|
||||
:root {
|
||||
--dock: #08252b;
|
||||
--dock-deep: #051115;
|
||||
@@ -19,10 +25,172 @@
|
||||
--sunset: #f0b66b;
|
||||
--list-selection: #1e4d54;
|
||||
--dock-selection: #1d4850;
|
||||
/* A button's resting/hover/active background - was hardcoded (#172a31/#203941/
|
||||
#28515a) in a handful of spots in RenamePanel.css; pulled out to variables here so
|
||||
every theme can supply its own shade instead of leaving a dark box behind on a
|
||||
light theme. */
|
||||
--btn-bg: #172a31;
|
||||
--btn-bg-hover: #203941;
|
||||
--btn-bg-active: #28515a;
|
||||
/* A small floating popup's background (the Load-files split-button menu). */
|
||||
--popup-bg: #16262d;
|
||||
/* The primary action button's hover shade - a lightened version of --aurora-green. */
|
||||
--accent-hover: #70e3bb;
|
||||
/* The header bar's top-to-bottom gradient stops (its middle stop is --surface-alt). */
|
||||
--header-grad-a: #111e26;
|
||||
--header-grad-b: #0a161c;
|
||||
/* Subtle highlight tints for row/button hover states, each a light-on-dark overlay
|
||||
for this theme - previously hardcoded as literal rgba(255,255,255,X) across
|
||||
RenamePanel.css, which is exactly backwards on a light theme (a white tint on an
|
||||
already-light surface is invisible). Named by their alpha so the light theme below
|
||||
can supply the mirror-image dark tints at matching intensities. */
|
||||
--tint-02: rgba(255, 255, 255, 0.02);
|
||||
--tint-03: rgba(255, 255, 255, 0.03);
|
||||
--tint-05: rgba(255, 255, 255, 0.05);
|
||||
--tint-06: rgba(255, 255, 255, 0.06);
|
||||
--tint-16: rgba(255, 255, 255, 0.16);
|
||||
--sidebar-width: 158px;
|
||||
--header-height: 68px;
|
||||
}
|
||||
|
||||
/* White - a clean white-background/black-text theme, the literal "white" half of the
|
||||
theming request. --dock-deep doubles as "the text color that sits well on top of
|
||||
--aurora-green" (see RenamePanel.css's .action-button.primary) - here that's white,
|
||||
since this theme's --aurora-green is deliberately darkened for contrast on a white
|
||||
page, the opposite of the dark themes' light-green-on-dark-text pairing. Keeps
|
||||
colorful accents (green/cyan/lilac) unlike Black below, which is deliberately
|
||||
grayscale - that's the visual difference between the two beyond just background. */
|
||||
:root[data-theme="white"] {
|
||||
--dock: #eef2f3;
|
||||
--dock-deep: #ffffff;
|
||||
--dock-mist: #dde7e9;
|
||||
--dock-line: #c7d3d5;
|
||||
--surface: #ffffff;
|
||||
--surface-alt: #f5f7f8;
|
||||
--surface-deep: #dfe6e8;
|
||||
--card: #f7f9fa;
|
||||
--text: #15191b;
|
||||
--muted-text: #5c6b6e;
|
||||
--aurora-green: #1f9d74;
|
||||
--aurora-cyan: #157e9c;
|
||||
--aurora-lilac: #7a68d9;
|
||||
--sunset: #c97a1f;
|
||||
--list-selection: #d7ece7;
|
||||
--dock-selection: #dbeee9;
|
||||
--btn-bg: #eef2f3;
|
||||
--btn-bg-hover: #e2e9eb;
|
||||
--btn-bg-active: #d2dcdd;
|
||||
--popup-bg: #ffffff;
|
||||
--accent-hover: #29b98b;
|
||||
--header-grad-a: #ffffff;
|
||||
--header-grad-b: #eef2f3;
|
||||
--tint-02: rgba(0, 0, 0, 0.02);
|
||||
--tint-03: rgba(0, 0, 0, 0.035);
|
||||
--tint-05: rgba(0, 0, 0, 0.05);
|
||||
--tint-06: rgba(0, 0, 0, 0.07);
|
||||
--tint-16: rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
|
||||
/* Black - a true near-black/white theme, the literal "black" half of the theming
|
||||
request. Deliberately grayscale (no hue in --aurora-green/cyan/lilac, unlike every
|
||||
other theme including White above) so it reads as an actual black-and-white theme
|
||||
rather than just "Aurora but darker" - --aurora-green here is a light neutral gray
|
||||
that still works as the primary-button accent against --dock-deep's pure black. */
|
||||
:root[data-theme="black"] {
|
||||
--dock: #0a0a0a;
|
||||
--dock-deep: #000000;
|
||||
--dock-mist: #1a1a1a;
|
||||
--dock-line: #2e2e2e;
|
||||
--surface: #000000;
|
||||
--surface-alt: #0d0d0d;
|
||||
--surface-deep: #262626;
|
||||
--card: #111111;
|
||||
--text: #f5f5f5;
|
||||
--muted-text: #9a9a9a;
|
||||
--aurora-green: #e5e5e5;
|
||||
--aurora-cyan: #d0d0d0;
|
||||
--aurora-lilac: #bfbfbf;
|
||||
--sunset: #cfcfcf;
|
||||
--list-selection: #262626;
|
||||
--dock-selection: #202020;
|
||||
--btn-bg: #141414;
|
||||
--btn-bg-hover: #1f1f1f;
|
||||
--btn-bg-active: #2a2a2a;
|
||||
--popup-bg: #0d0d0d;
|
||||
--accent-hover: #ffffff;
|
||||
--header-grad-a: #050505;
|
||||
--header-grad-b: #000000;
|
||||
--tint-02: rgba(255, 255, 255, 0.02);
|
||||
--tint-03: rgba(255, 255, 255, 0.03);
|
||||
--tint-05: rgba(255, 255, 255, 0.05);
|
||||
--tint-06: rgba(255, 255, 255, 0.06);
|
||||
--tint-16: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
/* Ocean - a cool blue-toned dark variant. */
|
||||
:root[data-theme="ocean"] {
|
||||
--dock: #071c2e;
|
||||
--dock-deep: #030f1c;
|
||||
--dock-mist: #0f3350;
|
||||
--dock-line: #1c4a68;
|
||||
--surface: #071620;
|
||||
--surface-alt: #0c1e2b;
|
||||
--surface-deep: #1a3548;
|
||||
--card: #0f232f;
|
||||
--text: #e7f2f8;
|
||||
--muted-text: #93aebe;
|
||||
--aurora-green: #4fd0e8;
|
||||
--aurora-cyan: #5aa9ff;
|
||||
--aurora-lilac: #8ea0ff;
|
||||
--sunset: #ffb46b;
|
||||
--list-selection: #1a3f57;
|
||||
--dock-selection: #193a52;
|
||||
--btn-bg: #12293a;
|
||||
--btn-bg-hover: #1a3a4f;
|
||||
--btn-bg-active: #234a63;
|
||||
--popup-bg: #102432;
|
||||
--accent-hover: #7fe0f2;
|
||||
--header-grad-a: #0c1f2e;
|
||||
--header-grad-b: #061420;
|
||||
--tint-02: rgba(255, 255, 255, 0.02);
|
||||
--tint-03: rgba(255, 255, 255, 0.03);
|
||||
--tint-05: rgba(255, 255, 255, 0.05);
|
||||
--tint-06: rgba(255, 255, 255, 0.06);
|
||||
--tint-16: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
/* Sunset - a warm amber/coral-toned dark variant. */
|
||||
:root[data-theme="sunset"] {
|
||||
--dock: #2b1408;
|
||||
--dock-deep: #150a04;
|
||||
--dock-mist: #3d2011;
|
||||
--dock-line: #5c3319;
|
||||
--surface: #1a0f08;
|
||||
--surface-alt: #23140c;
|
||||
--surface-deep: #40261a;
|
||||
--card: #241610;
|
||||
--text: #f7ece3;
|
||||
--muted-text: #c2a190;
|
||||
--aurora-green: #f0955a;
|
||||
--aurora-cyan: #f0b66b;
|
||||
--aurora-lilac: #e0866f;
|
||||
--sunset: #ffd08a;
|
||||
--list-selection: #4a2c17;
|
||||
--dock-selection: #452a16;
|
||||
--btn-bg: #2c1a10;
|
||||
--btn-bg-hover: #3c2416;
|
||||
--btn-bg-active: #4f301d;
|
||||
--popup-bg: #24160e;
|
||||
--accent-hover: #f5b083;
|
||||
--header-grad-a: #24140b;
|
||||
--header-grad-b: #140b05;
|
||||
--tint-02: rgba(255, 255, 255, 0.02);
|
||||
--tint-03: rgba(255, 255, 255, 0.03);
|
||||
--tint-05: rgba(255, 255, 255, 0.05);
|
||||
--tint-06: rgba(255, 255, 255, 0.06);
|
||||
--tint-16: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user