13 Commits
Author SHA1 Message Date
Renamr Scaffold ce115978c5 fix: naming-scheme short field names ({n}, {s00e00}, etc.) are now real clickable chips
Previously these only appeared as plain, non-clickable <code> text in the hint
paragraph below the field-chip groups, styled almost identically to the real
chips (same tinted-pill background) - a user reported field references as
'not clickable, you have to type it out' after clicking that inert text
instead of the real chips above it. Added a proper 'Short names' chip group
so they're actually clickable, and changed the remaining plain hint-text code
snippets to a dotted underline instead of a pill background so they can't be
mistaken for buttons again.
2026-08-30 12:16:52 +00:00
Renamr Scaffold 56f57545ac docs: accurate Linux/macOS/Windows build prerequisites and updated verification status
Windows section now gives real, actionable steps (MSVC toolchain via rustup,
VS Build Tools with the Desktop development with C++ workload, WebView2 note,
NSIS auto-download note) instead of a one-line mention. Also corrects the
stale verification paragraph: test count (199, not 160) and confirms the
production build now launches and runs correctly outside dev mode on both
macOS and Linux (Windows remains the one unverified platform).
2026-08-30 06:13:22 +00:00
Renamr Scaffold 8118a53e35 Split the "white/black" theme into two separate White and Black themes
User feedback after delivery 26: "wheres white and black themes?" -
the previous "Light" theme (white bg, black text) was a reasonable
literal reading of "have white/black" as one combined theme, but the
user meant two distinct named themes.

Renamed that theme's id/label from "light" to "White" and added a
genuinely new "Black" theme: a true near-black background with a
deliberately grayscale accent (light neutral grays instead of any
hue), so it reads as an actual black-and-white theme rather than
"Aurora but darker" - Aurora already covers the teal/green-accented
dark look. White keeps its colorful green/cyan/lilac accents, which
is now the visual difference between the two beyond just background
color. Settings now lists 5 themes total (Aurora/White/Black/Ocean/
Sunset); the picker grid moved from 2 to 3 columns to fit them
better.

Verified with an expanded Playwright script (12 checks, up from 10):
confirms all 5 themes list correctly, White and Black each apply
their real computed background color (not just the data-theme
attribute) and persist via save_theme, and the rest of the boot-time/
persistence behavior from delivery 26 still holds. All prior
verification suites (47 checks), `cargo test --workspace` (162
tests), and `npm run build` still pass clean.
2026-08-30 05:32:25 +00:00
Renamr Scaffold 03390b43f1 Add a Settings area with 4 color themes (Aurora, Light, Ocean, Sunset)
Requested directly: "lets add some themeing into it now, perhaps
under the configuration, have white/black, and some more colors."
There was no existing app-wide Settings/Configuration surface -
every prior setting lived in its own panel-specific modal (Fetch
Data Settings, subtitle provider settings) - so this adds the
app's first one: a palette-icon button in the sidebar footer opens
a new Settings modal with a 2x2 grid of theme swatches.

Four themes: Aurora (the existing dark teal/green look, unchanged,
stays the default), Light (a clean white/black theme - the literal
"white/black" ask), Ocean (a cool blue-toned dark variant), and
Sunset (a warm amber-toned dark variant) - the "some more colors"
part. Picking one applies instantly via a `data-theme` attribute on
<html> and persists through new get_theme/save_theme commands
(added to settings.rs/lib.rs alongside delivery 25's movie-naming-
scheme fix), applied on every future launch by a boot-time effect
in App.tsx so it takes effect even if Settings is never reopened.

theme.css's variable set was expanded to cover every place a color
was previously hardcoded outside the existing --dock/--surface/etc.
variables, so the whole app actually re-themes instead of leaving
dark boxes behind on the Light theme: button resting/hover/active
shades, the Load-files popup background, the primary button's hover
tint, the header's gradient stops, and five levels of white-tint
row/button hover highlights (RenamePanel.css/Header.css) all moved
to new variables (--btn-bg*, --popup-bg, --accent-hover, --header-
grad-a/b, --tint-02/03/05/06/16) with theme-specific values - the
light theme in particular needed dark tints instead of white ones
for hover states to stay visible on a white background.

Verified with a new Playwright script covering: default theme on a
fresh install, the Settings modal listing all 4 themes with the
right one marked selected, clicking a theme applying it immediately
(actual computed background color and CSS variable checked, not
just the data-theme attribute) and saving it, and a previously-saved
theme applying on boot before Settings is ever opened. Screenshots
taken of all 4 themes across the main view and the Naming Scheme
editor (whose buttons/popups were part of the hardcoded-color
cleanup) to confirm visually. All prior verification suites (47
checks across naming-scheme/match-fetch/hash-files/movie-scheme),
`cargo test --workspace` (162 tests), and `npm run build` still
pass clean.
2026-08-30 05:24:48 +00:00
Renamr Scaffold 635d57f55b Give movies their own naming scheme, fixing the "- x -" blank-preview bug
The Rename panel used a single shared naming-scheme expression for
every item regardless of media kind. A movie item never gets
"show name"/"season number"/"episode number"/"episode name" bindings
(guess_movie sets "n"/"y"/"movie name"/"year" instead) - so rendering
a movie against a TV-oriented scheme like
"{show name} - {season number}x{episode number} - {episode name}"
left nothing but the scheme's own literal text: "- x -", exactly as
reported (screenshot of a real movie file after Match).

TV and movie items now render against two independent naming
schemes (`expression` for TV, a new `movieExpression` for movies),
each persisted separately (new `last_movie_naming_scheme` settings
field/commands, mirroring the existing TV ones) and each editable
through a "TV Shows" / "Movies" toggle added to the Naming Scheme
editor - presets, field chips, and the 1-9 hotkeys all act on
whichever one is currently selected. The movie scheme needs no
onboarding auto-upgrade chain (unlike TV's sample -> matched ->
fetched staging): a movie item only ever has a title and year, both
already available right after local Match, so it just starts at a
sensible default ("{movie name} ({year})") instead.

A mixed batch of TV episodes and movies now renders both kinds
correctly at the same time, each against its own scheme. Verified
with a new Playwright script reproducing the exact reported scenario
(load a movie, click Match) plus the toggle/independent-persistence
behavior; all prior naming-scheme/match-fetch/hash-files suites,
`cargo test --workspace` (162 tests), and `npm run build` still pass
clean.
2026-08-30 05:19:10 +00:00
Renamr Scaffold 8cabe7c9ff Fix naming scheme silently resetting when a restored scheme collides with a milestone default
The Match/Fetch "showcase the next field" auto-upgrade heuristics in
RenamePanel.tsx each detected "the user hasn't customized the naming
scheme yet" via an exact string match against SAMPLE_EXPRESSION /
REAL_FILE_EXPRESSION / MATCHED_EXPRESSION. That was safe before the
naming-scheme persistence feature existed, since in-memory expression
state could only reach one of those exact strings by progressing
through the auto-upgrade chain itself.

Once persistence restores an arbitrary saved string on mount, a
user's real custom scheme can legitimately equal one of those
constants (this project's own default sample scheme is textually
identical to the fully-fetched milestone expression) - so loading
files, matching, or fetching would silently reset a correctly
restored scheme back to a "simpler" milestone the next time a
checkpoint's condition happened to match, undoing the just-loaded
persisted value.

Added restoredCustomSchemeRef, set once a real non-blank scheme is
restored from disk, and gated all three auto-upgrade checkpoints on
it so the onboarding auto-simplify chain only ever fires for a
genuinely fresh session with nothing persisted - never for a
returning session whose scheme was explicitly restored, regardless
of what that restored text happens to equal. Verified with a new
Playwright script reproducing the exact collision (persisted scheme
== SAMPLE_EXPRESSION), confirming the scheme now survives Load
Files, Match, and Fetch Data intact; all prior naming-scheme/
match-fetch/hash-files verification suites and `npm run build`
still pass clean.
2026-08-30 05:05:22 +00:00
Renamr Scaffold 6d98750642 File hashing in naming schemes: {crc32}/{md5}/{sha1} via Hash Files
Closes the last remaining gap in porting-roadmap item 1 (full naming-
scheme bindings) - user picked this from a short list of next-feature
options after the recent naming-scheme/Match usability work.

crates/aurora-curator-core/src/sfv: new compute_all_checksums(path),
alongside the existing compute_checksum(path, kind). Streams the file
ONCE, updating CRC32/MD5/SHA-1 hashers concurrently over each chunk,
rather than calling compute_checksum three times (three full reads of
what's often a multi-gigabyte video file). The SFV panel itself keeps
using compute_checksum directly - it only ever needs one algorithm at a
time, picked by the user, so computing the other two nobody asked for
would be wasteful in the other direction. 3 new tests: matches known
CRC32/MD5/SHA-1 test vectors together, agrees with compute_checksum on a
file bigger than the read buffer (catches a hasher only seeing the first
chunk), errors on a missing file.

src-tauri/src/sfv.rs: new NamingHashOutcome struct (plain
#[serde(rename_all = "camelCase")] struct, not a tagged enum - the class
of bug fixed in the nineteenth delivery doesn't apply here) and
compute_naming_hashes command, batching compute_all_checksums the same
blocking-thread way compute_checksums already does, with the same
per-file error isolation (one bad file doesn't abort the batch).
Registered in lib.rs.

RenamePanel.tsx: new runHashFiles() and a "Hash Files" button (new
HashIcon in Icons.tsx) next to Fetch Data. Deliberately NOT wired into
Match automatically like the rest of its local steps - hashing reads a
file's entire contents rather than a small header, so running it
unasked on every Match click for every loaded file would make Match
unpredictably slow. Its own busy state (hashing) disables Match/Fetch
Data/Hash Files against each other while any one is running. New "File
hashing" field-reference group ({crc32}/{md5}/{sha1} as clickable chips,
reusing the field-chip insert-at-cursor mechanism from the earlier
naming-scheme UX delivery) and updated inline help text.

Verified via cargo test --workspace (191 -> 193 in the core crate: 145 ->
148) + cargo check --workspace (clean) + npm run build (clean) + a
10-check Playwright script against npm run dev (mocking
window.__TAURI_INTERNALS__.invoke): confirmed the three field chips are
present, Hash Files calls compute_naming_hashes with every loaded path,
the status line correctly reports hashed-vs-failed counts, a computed
CRC32 reaches the live rename preview, a failed file's row doesn't
fabricate a hash, and clicking Hash Files with nothing loaded is handled
gracefully. Caught one test-mock bug along the way (not an app bug): an
early mock response for a successful hash omitted the `error` field
entirely rather than setting it to `null`, which the app correctly (per
the real Rust command's always-present Option<String> field) treated as
a truthy non-null value and skipped merging - fixed the mock to match
the real wire contract.

Updated README (status paragraph, project layout for sfv.rs's now-four
commands, a new "Hash Files" section placed after "Real media-file
probing", porting roadmap item 1's "still missing" list).
2026-08-30 04:53:04 +00:00
Renamr Scaffold 25dedc1c36 Remove remaining user-visible FileBot mentions
The full rebrand (session 9, seventh delivery) covered the app's own
identity - name, icon, no FileBot/FB-Mod branding visible - but two
literal "FileBot" mentions were still reachable by the end user, both
found via a targeted grep for FileBot/Filebot/filebot/FB-Mod across every
string actually rendered to a user (JSX text and CLI help/usage text),
as opposed to source comments and README documentation, which still
legitimately explain what this project is a rewrite of and aren't shown
inside the running app:

- The Naming Scheme editor's own inline help text described the FileBot-
  style short field names ({n}, {s00e00}, {t}, etc.) as "FileBot's own
  short field names" - reworded to just "Short field names", same
  meaning, no brand name.
- The CLI's -h/--help output described -non-strict as "Accepted for
  FileBot script compatibility" - reworded to "Accepted for compatibility
  with existing rename scripts".

Grepped the whole frontend and every Rust source string (not just these
two files) to confirm nothing else user-visible remained - tauri.conf.json,
Cargo.toml, and package.json were already clean. What's left after this
is exclusively source-code comments/doc-comments and README.md, none of
which the app itself ever displays.

Verified via cargo test --workspace (193/193, unaffected - text-only
change) + npm run build (clean).
2026-08-30 04:41:43 +00:00
Renamr Scaffold 9b620bb7d2 Match now automatically runs Fetch Data right after - one click, not two
Requested directly: "when you click Match, I would prefer it to do what
the lookup does. pulls the info, renames properly. just one less step
needed." Match's local filename scan and Fetch Data's online lookup were
two separate button clicks with no relationship between them in code -
Fetch Data just happened to require Match to have already run.

New runMatchAndFetch() wraps both: runs the same local match as before,
then - if it found anything recognizable and either a credential-free
source (TVmaze) is selected or the needed credential is already saved -
immediately fetches real online metadata for it too, exactly as if the
user had clicked both buttons in sequence. The standalone "Fetch Data"
button next to New Names is untouched and still works on its own, for
re-fetching after changing the metadata source or fixing a guessed name
by hand.

Two supporting changes made this safe to chain:
- runMatching() now computes its updated items as a plain array
  (`items.map(...)`) instead of the usual `setItems((prev) =>
  prev.map(...))` functional-update form, and returns that array. Chaining
  straight into runFetch() needed the *actual* just-matched items, not
  `items` React state - which wouldn't reflect the match yet, since
  setItems() only queues a re-render rather than updating what this
  function's own closure sees.
- runFetch() gained itemsOverride/silentIfEmpty/chained options: read
  targets from the array just handed to it rather than state; skip the
  "Run Match first" message when Match simply found nothing to look up
  (its own message already covers that); and skip auto-opening the
  Settings modal on a missing credential when chained, so someone who
  hasn't set up Fetch Data (or doesn't want to) isn't interrupted by a
  popup on every single Match click - the status line still explains why
  fields are staying blank either way.

Also fixed a status-message race: both functions clear their own status
after a timeout, and chaining them naively could let Match's leftover
timer blank out Fetch Data's result message early (observed while
testing: a fast TVmaze lookup's "Fetched..." message got wiped a few
seconds later by Match's own stale timer). Match now skips scheduling its
success-path timer when chained, so whichever step actually finishes last
owns the status line's timer.

Verified via npm run build (clean, no Rust changes needed) + an 11-check
Playwright script against npm run dev (mocking
window.__TAURI_INTERNALS__.invoke): confirmed Match triggers guess_episode
then automatically fetch_metadata with no separate click, in that order;
the fetched episode name reaches the live preview; a missing TMDB token
still runs the local match and explains the gap in the status line
without forcing the Settings modal open; and clicking Match with nothing
loaded is handled gracefully.

Updated the naming-scheme editor's inline help text and the Match
button's tooltip to describe the new combined behavior.
2026-08-30 04:33:38 +00:00
Renamr Scaffold b409d67510 Naming scheme editor: clickable field tokens + persist across restarts
Two usability fixes to the naming-scheme editor, both reported/requested
directly: the field-reference list ({show name}, {episode number}, etc.)
was plain unclickable text, so building a scheme meant typing every field
name out by hand; and the editor's text always reset to the built-in
sample expression on every launch, since nothing ever wrote it to disk -
only the explicit 1-9 preset slots (presets.rs) were persisted.

Field list -> clickable buttons: each field reference is now a real
<button> (.format-field-chip) wired to insertField(), which reads the
input's real DOM selectionStart/selectionEnd and inserts the token at
the caret, then restores focus and moves the caret to just after the
inserted text so clicking several fields in a row builds up
left-to-right. Purely a frontend change - no new Tauri commands needed.

Persistence: added last_naming_scheme to settings.rs's StoredSettings
(same settings.json already used for tv/movie source, credentials,
etc.), plus get_last_naming_scheme/save_last_naming_scheme commands.
RenamePanel.tsx loads it once on mount (restoring the saved scheme
before the user can interact with anything) and saves it back debounced
(600ms after typing settles) as the user edits - gated behind a
schemeLoaded flag so the save effect can never fire before the load
completes and clobber a saved scheme with the not-yet-replaced sample
default. An empty/blank saved value (fresh install) leaves the built-in
sample expression in place, same fallback behavior as every other
optional setting in this file.

Verified via cargo test --workspace (191 -> 193, two new settings.rs
tests: schema-evolution for the new field, and the blank-clears-to-None
behavior) + npm run build (clean) + a 7-check Playwright script against
npm run dev (mocking window.__TAURI_INTERNALS__.invoke): confirmed the
editor restores a mocked saved scheme on launch, clicking a field chip
inserts its token at a caret placed via setSelectionRange (not just
appended), a second click lands immediately after the first rather than
at the old position, and save_last_naming_scheme fires once (debounced)
with the exact edited text rather than on every keystroke.

Updated README's naming-scheme paragraph to describe both.
2026-08-30 04:20:59 +00:00
Renamr Scaffold ecd98d6c57 Fix serde field-naming bug that blanked show/episode names, real renames, and Undo
`#[serde(tag = "status", rename_all = "camelCase")]` on a tagged enum only
renames the tag's own values ("Found" -> "found"), not the field names
inside each struct-like variant - so `series_name` stayed `series_name` on
the wire instead of becoming `seriesName`. The frontend only ever reads
camelCase, so these fields came back `undefined`, and JSON.stringify
silently drops undefined keys when Tauri invoke args are serialized -
making the bug invisible rather than throwing.

This is why Fetch Data (TMDB/TVmaze/OMDb) blanked out {show name} and
{episode name} in naming schemes even on a full "found" result: reported
by the user with screenshots showing the naming preview correct after
Match but blank after Fetch Data.

The same pattern existed in 4 more places, fixed identically by adding
`rename_all_fields = "camelCase"` alongside the existing tag/rename_all:
- metadata.rs: FetchOutcome (the reported bug) and MovieFetchOutcome
- episodes.rs: EpisodeListOutcome
- history/mod.rs: RevertOutcome (restoredPath) - broke Undo reconciliation
- rename/mod.rs: RenameOutcome (newPath) - broke post-rename item tracking
  after a REAL on-disk rename; RenamePanel.tsx calls
  fileNameFromPath(result.outcome.newPath), which throws on undefined,
  so this was likely the most severe of the five.

No frontend changes needed - the TypeScript side already expected
camelCase throughout. Added 5 permanent regression tests (one per fixed
enum, two in metadata.rs) that assert the exact camelCase JSON keys and
assert the old snake_case keys are absent. cargo test --workspace: 186 ->
191 passing. npm run build: clean, no frontend diff.
2026-08-30 04:12:17 +00:00
Renamr Scaffold e219e8f2a8 Wire real media probing into Filter and List panels
Extends the sixteenth delivery's container-header probing
(probe_media_files) beyond the Rename panel: Filter and List now run the
same batched probe alongside their existing local scan, using the same
override rule everywhere - a filename tag wins when present, otherwise a
real probed value fills in.

List: {width}/{height}/{fps}/{duration}/{file size} are now available in
naming-scheme-style expressions (no filename-tag equivalent exists), and a
probed {resolution}/{video codec}/{audio codec} fills in when the filename
didn't already carry that tag.

Filter: gained Resolution and Video codec chip rows (populated from the
same merged filename-tag-or-probed value, with an Unknown bucket for files
with neither) so a batch can be narrowed by either. Each result row's
subtitle now also shows the resolved resolution when one exists.

Verified via cargo check/test --workspace (unaffected, frontend-only
change) + npm run build (clean) + a 15-check Playwright script against
npm run dev covering both panels: filename-tag resolution winning over a
conflicting probed value, probed fields filling in for an otherwise-
unmatched file, the new Filter chip rows narrowing results correctly.
Updated README (status paragraph, Filter section, List section, Real
media-file probing section, roadmap item 1).
2026-08-30 03:48:22 +00:00
Renamr Scaffold 23c9ca609f Expanded scripting/naming helpers: pad, sort_name/sort_initial, upper/lower_initial, acronym, validate_file_name, space/space_with, colon/colon_with
Ports the next slice of FileBot's ExpressionFormatMethods.java as Rhai-callable
methods usable inside any { } naming-scheme expression, alongside the existing
any/all_of/concat helpers:

- pad/pad_with: left-pad a value's string form (default '0'), non-mutating,
  non-truncating - deliberately a different Rhai method name for the 3-arg
  custom-char form (pad_with) since Rhai's own built-in pad(length, char)
  right-pads and mutates in place, which collides in behavior (not API) with
  what FileBot's zero-padded season/episode numbers need.
- sort_name/sort_initial: move a leading The/A/An article to the end for
  alphabetical sorting, e.g. "The Office" -> "Office, The".
- upper_initial/lower_initial: case-convert only the first character.
- acronym: first letter of each word, uppercased.
- validate_file_name: strip characters illegal in filenames, collapse
  whitespace runs, trim trailing dots.
- space/space_with, colon/colon_with: separator and colon normalization.

16 new unit tests in functions.rs (143 lib tests total, up from 126) plus 3
new integration tests in expression_format.rs demonstrating the helpers used
inside real naming-scheme expressions (14 total, up from 11). Verified via
cargo test --workspace, cargo check --workspace, and npm run build, all
clean. Updated README (status paragraph and porting-roadmap item 2).
2026-08-30 03:24:19 +00:00
24 changed files with 1989 additions and 201 deletions
+158 -68
View File
@@ -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");
}
}
+17 -1
View File
@@ -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();
+23 -1
View File
@@ -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");
+80
View File
@@ -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");
}
+3 -2
View File
@@ -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
+17 -1
View File
@@ -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"));
}
}
+7
View File
@@ -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,
+44 -2
View File
@@ -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"));
}
}
+165
View File
@@ -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
+45
View File
@@ -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
View File
@@ -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} />
+146 -7
View File
@@ -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">
+1 -1
View File
@@ -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;
}
+23
View File
@@ -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>
);
}
+71 -7
View File
@@ -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">
+41 -22
View File
@@ -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
View File
@@ -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.{" "}
+63
View File
@@ -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;
}
+109
View File
@@ -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>
);
}
+19
View File
@@ -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;
+14 -1
View File
@@ -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
View File
@@ -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;
}