Compare commits
13
Commits
1291ae086d
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce115978c5 | ||
|
|
56f57545ac | ||
|
|
8118a53e35 | ||
|
|
03390b43f1 | ||
|
|
635d57f55b | ||
|
|
8cabe7c9ff | ||
|
|
6d98750642 | ||
|
|
25dedc1c36 | ||
|
|
9b620bb7d2 | ||
|
|
b409d67510 | ||
|
|
ecd98d6c57 | ||
|
|
e219e8f2a8 | ||
|
|
23c9ca609f |
@@ -35,7 +35,9 @@ file's own container header for its real resolution, frame rate, duration, and
|
|||||||
codecs — no ffmpeg/MediaInfo dependency required — filling in `{resolution}`/
|
codecs — no ffmpeg/MediaInfo dependency required — filling in `{resolution}`/
|
||||||
`{video codec}`/`{audio codec}` whenever the filename itself didn't already carry
|
`{video codec}`/`{audio codec}` whenever the filename itself didn't already carry
|
||||||
that tag, plus `{width}`/`{height}`/`{fps}`/`{duration}`/`{file size}`, which have no
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
The naming scheme is fully user-editable from the app (the "Naming Scheme" button next
|
||||||
to the New Names list) — the default is `{show name} - {season number}x{episode
|
to the New Names list) — the default is `{show name} - {season number}x{episode
|
||||||
@@ -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}`)
|
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
|
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
|
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
|
## 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
|
`browse_episodes` command backing the Episodes panel — series
|
||||||
search + one season's episode list, via a `build_tv_client` helper
|
search + one season's episode list, via a `build_tv_client` helper
|
||||||
now shared with `metadata.rs`'s `fetch_metadata`, so both read the
|
now shared with `metadata.rs`'s `fetch_metadata`, so both read the
|
||||||
same stored TV-source setting and credential), `sfv.rs` (three
|
same stored TV-source setting and credential), `sfv.rs` (four
|
||||||
commands backing the SFV panel — `compute_checksums` over a batch
|
commands backing the SFV panel and the Rename panel's Hash Files
|
||||||
of loaded files with per-file error isolation, `save_checksum_file`
|
button — `compute_checksums` over a batch of loaded files with
|
||||||
via a native "Save File" dialog, and `verify_checksum_file`, which
|
per-file error isolation, `save_checksum_file` via a native "Save
|
||||||
reads and parses a picked `.sfv`/`.md5`/`.sha1` file and resolves
|
File" dialog, `verify_checksum_file`, which reads and parses a
|
||||||
each entry's bare filename relative to that file's own parent
|
picked `.sfv`/`.md5`/`.sha1` file and resolves each entry's bare
|
||||||
directory — all wrapping `aurora-curator-core::sfv`, which does the
|
filename relative to that file's own parent directory, and
|
||||||
actual algorithm/format work),
|
`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
|
`history.rs` (rename history/undo
|
||||||
log), `presets.rs` (up to 9 saved naming schemes) — all three
|
log), `presets.rs` (up to 9 saved naming schemes) — all three
|
||||||
stored as JSON in the OS app-config directory — `list.rs` (a
|
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
|
## Building
|
||||||
|
|
||||||
Prerequisites: Rust (stable) + Cargo, Node.js 18+, and Tauri's platform prerequisites
|
Prerequisites: Rust (stable) + Cargo, Node.js 18+, and Tauri's platform prerequisites
|
||||||
(see https://v2.tauri.app/start/prerequisites/ — on Linux this means
|
(see https://v2.tauri.app/start/prerequisites/ for the full, up-to-date list).
|
||||||
`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).
|
**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
|
```sh
|
||||||
# Rust core (no UI deps needed):
|
# Rust core (no UI deps needed):
|
||||||
@@ -319,14 +353,20 @@ npm run tauri dev
|
|||||||
npm run tauri build
|
npm run tauri build
|
||||||
```
|
```
|
||||||
|
|
||||||
Verified in the environment this was built in: `cargo test --workspace` (all 160
|
`npm run tauri build` produces a native installer for whichever OS you run it on — there
|
||||||
passing, including the TMDB, TVmaze, OMDb, and OpenSubtitles client tests — those run
|
is no cross-compilation; build on Windows to get a `.exe`/`.msi`, on Linux for
|
||||||
against a local mock HTTP server via `mockito`, no live network or API key needed to
|
`.deb`/`.rpm`/`.AppImage`, on macOS for a `.app`/`.dmg`.
|
||||||
build/test), `cargo
|
|
||||||
check -p aurora-curator` (compiles clean against real Tauri + GTK/WebKit system
|
Verified so far: `cargo test --workspace` (199 tests passing, including the TMDB,
|
||||||
libraries), and `npm run build` (TypeScript type-checks, Vite bundles). The actual GUI
|
TVmaze, OMDb, and OpenSubtitles client tests — those run against a local mock HTTP
|
||||||
window has **not** been visually verified anywhere yet — that sandbox has no display.
|
server via `mockito`, no live network or API key needed to build/test), `cargo check -p
|
||||||
Run `npm run tauri dev` on your machine to see and click through it for the first time.
|
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")
|
### 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
|
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
|
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,
|
the matching paths out, or just get a sense of what's actually in a folder. It scans every
|
||||||
it adds no new Rust/Tauri code at all: it scans every loaded file with the exact same
|
loaded file with the exact same local, filename-only matchers "Match" uses
|
||||||
local, filename-only matchers "Match" uses (`guess_episode`/`guess_movie`/
|
(`guess_episode`/`guess_movie`/`guess_release_info`) and turns the results into filter
|
||||||
`guess_release_info`) and turns the results into filter criteria instead of a rename
|
criteria instead of a rename preview — no network calls, so scanning even a large batch is
|
||||||
preview — no network calls, so scanning even a large batch is effectively instant.
|
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
|
**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
|
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
|
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
|
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
|
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
|
that season. Each result row's subtitle also shows a resolved resolution when one exists
|
||||||
**Copy File Paths** writes every currently-matching file's full path, one per line, to the
|
(e.g. `Unmatched · 480p` for a file Match couldn't identify as TV or a movie but still got
|
||||||
clipboard.
|
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
|
## 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
|
everything else (matching, and rendering each file's line) reuses commands that already
|
||||||
existed.
|
existed.
|
||||||
|
|
||||||
**Load Files…** loads a batch and scans it locally, the same way Filter does. The
|
**Load Files…** loads a batch and scans it locally, the same way Filter does — including
|
||||||
**Format** field holds the expression rendered against each file — the exact same `{ }`
|
the same batched real container-header probe (see "Real media-file probing" below), so
|
||||||
syntax the naming scheme uses, run through the same `render_expression` command the
|
List's expressions get real probed values too, not just filename-tag ones. The **Format**
|
||||||
Rename panel's live preview calls. `{name}`/`{ext}` always work (they're the file's own
|
field holds the expression rendered against each file — the exact same `{ }` syntax the
|
||||||
name/extension); `{show name}`/`{season number}`/`{episode number}`, `{movie name}`/
|
naming scheme uses, run through the same `render_expression` command the Rename panel's
|
||||||
`{year}`, and the release-tag fields (`{resolution}`/`{source}`/`{video codec}`/
|
live preview calls. `{name}`/`{ext}` always work (they're the file's own name/extension);
|
||||||
`{audio codec}`/`{release group}`) fill in once the local scan recognizes a file — there's
|
`{show name}`/`{season number}`/`{episode number}`, `{movie name}`/`{year}`, and the
|
||||||
no Fetch Data step here, List stays local-only like Filter. A file whose rendered line
|
release-tag fields (`{resolution}`/`{source}`/`{video codec}`/`{audio codec}`/`{release
|
||||||
comes out empty (e.g. an expression that only references a field this particular file
|
group}`) fill in once the local scan recognizes a file; `{width}`/`{height}`/`{fps}`/
|
||||||
never matched) shows an "(empty)" placeholder rather than blocking the rest of the list,
|
`{duration}`/`{file size}` come straight from real probing (no filename-tag equivalent
|
||||||
and the results header tracks "N of M lines rendered" live as the expression changes.
|
exists), and a probed `{resolution}`/`{video codec}`/`{audio codec}` fills in whenever the
|
||||||
**Copy List** copies every line, in file order, newline-joined, to the clipboard; **Export
|
filename didn't already carry that tag — there's no Fetch Data step here, List stays
|
||||||
to File…** opens a native Save dialog (defaulted to `list.txt`) and writes the same text
|
local-only like Filter. A file whose rendered line comes out empty (e.g. an expression
|
||||||
to disk.
|
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
|
## Real media-file probing
|
||||||
|
|
||||||
Unlike every panel above, this isn't a new sidebar screen — it's a naming-scheme
|
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)
|
binding source wired into the Rename panel's "Match" step, the Filter panel's scan, and
|
||||||
the gap the porting roadmap has called out since the beginning: FileBot's
|
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
|
`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
|
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
|
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
|
matching, never a hard requirement, so an unsupported or corrupt file can't break
|
||||||
Match.
|
Match.
|
||||||
|
|
||||||
Clicking **Match** now also probes every loaded file's real container header
|
Clicking **Match** (Rename), or loading a batch into **Filter** or **List**, now also
|
||||||
(`probe_media_files`, one batched Tauri round trip off the main thread) alongside the
|
probes every loaded file's real container header (`probe_media_files`, one batched Tauri
|
||||||
existing filename-based guessing. The result feeds the same bindings the release-tag
|
round trip off the main thread) alongside the existing filename-based guessing — all
|
||||||
parser already populates — `{resolution}`/`{vf}`, `{video codec}`/`{vc}`,
|
three panels call the exact same command the exact same way. The result feeds the same
|
||||||
`{audio codec}`/`{ac}` — but only when the filename itself didn't already carry that
|
bindings the release-tag parser already populates — `{resolution}`/`{vf}`, `{video
|
||||||
tag; an explicit release-group tag always wins over a probed value, since a release
|
codec}`/`{vc}`, `{audio codec}`/`{ac}` — but only when the filename itself didn't already
|
||||||
group's own tag is usually deliberate. Five bindings have no filename-tag equivalent
|
carry that tag; an explicit release-group tag always wins over a probed value, since a
|
||||||
at all, so they're only ever available this way: `{width}`, `{height}`, `{fps}`,
|
release group's own tag is usually deliberate. Five bindings have no filename-tag
|
||||||
`{duration}` (`h:mm:ss`, or `m:ss` under an hour), and `{file size}` (human-readable,
|
equivalent at all, so they're only ever available this way: `{width}`, `{height}`,
|
||||||
e.g. `1.4 GB`) — all formatted once on the Rust side so the UI never reimplements that
|
`{fps}`, `{duration}` (`h:mm:ss`, or `m:ss` under an hour), and `{file size}`
|
||||||
logic. The Match status line reports how many loaded files were actually probed
|
(human-readable, e.g. `1.4 GB`) — all formatted once on the Rust side so the UI never
|
||||||
(`"N probed from file"`), and the naming-scheme editor's field-reference list has a
|
reimplements that logic. Rename's Match status line, List's scan status, and Filter's
|
||||||
new "Real file probing" group listing all five.
|
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)
|
## 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
|
actually reading the file — real resolution/video codec/audio codec fill in
|
||||||
whenever the filename didn't already carry that tag, plus `{width}`/`{height}`/
|
whenever the filename didn't already carry that tag, plus `{width}`/`{height}`/
|
||||||
`{fps}`/`{duration}`/`{file size}`, which have no filename-tag equivalent at all —
|
`{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
|
for the two container families it supports (MP4/M4V/MOV and MKV/WebM). Probed
|
||||||
missing: probing for other containers (.avi, .wmv, ...), audio-only metadata
|
bindings are now wired into all three panels that do local matching — Rename's
|
||||||
(bitrate, sample rate, channel layout), file hashing (`{crc32}` reuses the SFV
|
Match step, and the Filter and List panels' own scans (Filter also gained
|
||||||
panel's own hasher today but isn't wired into naming-scheme bindings yet), and
|
Resolution/Video codec filter chips from it). File hashing is done too —
|
||||||
wiring probed bindings into the List/Filter panels (currently Rename-panel-only).
|
`{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`
|
2. **String/expression helper methods** — `net.filebot.format.ExpressionFormatMethods`
|
||||||
(636 lines): FileBot-specific Groovy string extensions like `.pad()`, `.sortName()`.
|
(636 lines): FileBot-specific Groovy string extensions like `.pad()`, `.sortName()`.
|
||||||
Only a small subset (`any`, `all_of`, `concat`) is ported so far
|
Ported so far (`crates/aurora-curator-core/src/format/functions.rs`): `any`, `all_of`,
|
||||||
(`crates/aurora-curator-core/src/format/functions.rs`); Rhai's built-ins cover some of the
|
`concat`, plus a newer batch of naming/scripting helpers — `pad`/`pad_with`
|
||||||
rest already.
|
(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
|
3. **Matching/similarity engine** — `net.filebot.similarity.*`. Local filename-based
|
||||||
season/episode detection is done (`crates/aurora-curator-core/src/matching`, ported from the
|
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
|
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])}`.
|
//! Global helper functions available inside every `{ }` expression, e.g. `{any([x, y])}`.
|
||||||
//!
|
//!
|
||||||
//! Starter subset ported from FileBot's `ExpressionFormatFunctions.java`. Rhai already
|
//! Starter subset ported from FileBot's `ExpressionFormatFunctions.java`. Rhai already
|
||||||
//! provides `to_upper`, `to_lower`, `trim`, `sub_string`, `len`, `contains`, `pad` and
|
//! provides `to_upper`, `to_lower`, `trim`, `sub_string`, `len`, `contains` and friends as
|
||||||
//! friends as built-in string methods, so this module only adds the FileBot-specific
|
//! built-in string methods, so this module only adds the FileBot-specific helpers that
|
||||||
//! helpers that don't already have a Rhai equivalent. More helpers (csv/read_lines/
|
//! don't already have a Rhai equivalent. More helpers (csv/read_lines/quote_bash/
|
||||||
//! quote_bash/quote_powershell, etc.) are tracked in the porting backlog.
|
//! 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};
|
use rhai::{Array, Dynamic, Engine};
|
||||||
|
|
||||||
@@ -44,9 +52,270 @@ fn concat(parts: Array) -> String {
|
|||||||
.join("")
|
.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) {
|
pub fn register(engine: &mut Engine) {
|
||||||
engine.register_fn("is_empty_value", is_empty_value);
|
engine.register_fn("is_empty_value", is_empty_value);
|
||||||
engine.register_fn("any", any_of);
|
engine.register_fn("any", any_of);
|
||||||
engine.register_fn("all_of", all_of);
|
engine.register_fn("all_of", all_of);
|
||||||
engine.register_fn("concat", concat);
|
engine.register_fn("concat", concat);
|
||||||
|
engine.register_fn("pad", pad);
|
||||||
|
engine.register_fn("pad_with", pad_with);
|
||||||
|
engine.register_fn("sort_name", sort_name);
|
||||||
|
engine.register_fn("sort_initial", sort_initial);
|
||||||
|
engine.register_fn("upper_initial", upper_initial);
|
||||||
|
engine.register_fn("lower_initial", lower_initial);
|
||||||
|
engine.register_fn("acronym", acronym);
|
||||||
|
engine.register_fn("validate_file_name", validate_file_name);
|
||||||
|
engine.register_fn("space", space);
|
||||||
|
engine.register_fn("space_with", space_with);
|
||||||
|
engine.register_fn("colon", colon);
|
||||||
|
engine.register_fn("colon_with", colon_with);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn eval(expr: &str) -> String {
|
||||||
|
let mut engine = Engine::new();
|
||||||
|
register(&mut engine);
|
||||||
|
engine.eval::<Dynamic>(expr).unwrap().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pad_left_pads_with_zero_by_default() {
|
||||||
|
assert_eq!(eval(r#""5".pad(2)"#), "05");
|
||||||
|
assert_eq!(eval(r#""5".pad(3)"#), "005");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pad_does_not_truncate_a_value_already_at_or_past_width() {
|
||||||
|
assert_eq!(eval(r#""123".pad(2)"#), "123");
|
||||||
|
assert_eq!(eval(r#""12".pad(2)"#), "12");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pad_accepts_a_custom_pad_character() {
|
||||||
|
assert_eq!(eval(r#""7".pad_with(3, "-")"#), "--7");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sort_name_moves_a_leading_article_to_the_end() {
|
||||||
|
assert_eq!(eval(r#""The Office".sort_name()"#), "Office, The");
|
||||||
|
assert_eq!(eval(r#""A Beautiful Mind".sort_name()"#), "Beautiful Mind, A");
|
||||||
|
assert_eq!(eval(r#""An Officer and a Gentleman".sort_name()"#), "Officer and a Gentleman, An");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sort_name_leaves_a_title_with_no_leading_article_unchanged() {
|
||||||
|
assert_eq!(eval(r#""Deadpool".sort_name()"#), "Deadpool");
|
||||||
|
// "A-Team" is not "A " + rest - no space after the article, so it doesn't match.
|
||||||
|
assert_eq!(eval(r#""A-Team".sort_name()"#), "A-Team");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sort_initial_strips_the_article_before_taking_the_first_letter() {
|
||||||
|
assert_eq!(eval(r#""The Office".sort_initial()"#), "O");
|
||||||
|
assert_eq!(eval(r#""Deadpool".sort_initial()"#), "D");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upper_and_lower_initial_only_touch_the_first_character() {
|
||||||
|
assert_eq!(eval(r#""breaking Bad".upper_initial()"#), "Breaking Bad");
|
||||||
|
assert_eq!(eval(r#""Breaking Bad".lower_initial()"#), "breaking Bad");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acronym_takes_the_first_letter_of_each_word_uppercased() {
|
||||||
|
assert_eq!(eval(r#""Attack on Titan".acronym()"#), "AOT");
|
||||||
|
assert_eq!(eval(r#""Full Metal Alchemist Brotherhood".acronym()"#), "FMAB");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_file_name_replaces_illegal_characters_with_a_space() {
|
||||||
|
assert_eq!(eval(r#""Who Framed Roger Rabbit?".validate_file_name()"#), "Who Framed Roger Rabbit");
|
||||||
|
assert_eq!(eval(r#""Aliens: Special Edition".validate_file_name()"#), "Aliens Special Edition");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_file_name_collapses_whitespace_and_strips_trailing_dots() {
|
||||||
|
assert_eq!(eval(r#""Weird: /Path\\Bits*".validate_file_name()"#), "Weird Path Bits");
|
||||||
|
assert_eq!(eval(r#""Trailing dot.".validate_file_name()"#), "Trailing dot");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn space_normalizes_separator_runs_to_a_single_space() {
|
||||||
|
assert_eq!(eval(r#""Breaking___Bad".space()"#), "Breaking Bad");
|
||||||
|
assert_eq!(eval(r#""Breaking.Bad.2008".space()"#), "Breaking Bad 2008");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn space_with_uses_a_custom_replacement_character() {
|
||||||
|
assert_eq!(eval(r#""Breaking Bad".space_with(".")"#), "Breaking.Bad");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colon_replaces_with_a_dash_by_default() {
|
||||||
|
assert_eq!(eval(r#""Aliens: Special Edition".colon()"#), "Aliens - Special Edition");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colon_with_uses_a_custom_replacement() {
|
||||||
|
assert_eq!(eval(r#""Aliens: Special Edition".colon_with(" -")"#), "Aliens - Special Edition");
|
||||||
|
assert_eq!(eval(r#""Aliens:Special".colon_with("")"#), "AliensSpecial");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ pub struct HistoryBatch {
|
|||||||
|
|
||||||
/// What happened when reverting one entry of a batch.
|
/// What happened when reverting one entry of a batch.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[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 {
|
pub enum RevertOutcome {
|
||||||
/// For a Move batch: successfully moved back to its original path. For a Copy or
|
/// 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
|
/// Symlink batch: the copy/symlink that was created has been removed - the original
|
||||||
@@ -180,6 +180,22 @@ mod tests {
|
|||||||
dir
|
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]
|
#[test]
|
||||||
fn reverts_a_renamed_file_back_to_its_original_path() {
|
fn reverts_a_renamed_file_back_to_its_original_path() {
|
||||||
let dir = temp_dir();
|
let dir = temp_dir();
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ impl Default for ConflictPolicy {
|
|||||||
|
|
||||||
/// What happened to one planned rename.
|
/// What happened to one planned rename.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[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 {
|
pub enum RenameOutcome {
|
||||||
/// Renamed successfully; `new_path` is the file's new full path.
|
/// Renamed successfully; `new_path` is the file's new full path.
|
||||||
Renamed { new_path: String },
|
Renamed { new_path: String },
|
||||||
@@ -329,6 +329,28 @@ mod tests {
|
|||||||
(dir, file)
|
(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]
|
#[test]
|
||||||
fn renames_a_file_successfully() {
|
fn renames_a_file_successfully() {
|
||||||
let (dir, file) = temp_file_setup("old.txt", "hello");
|
let (dir, file) = temp_file_setup("old.txt", "hello");
|
||||||
|
|||||||
@@ -98,6 +98,48 @@ pub fn compute_checksum(path: &Path, kind: ChecksumKind) -> io::Result<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All three checksums for one file, computed together - see [`compute_all_checksums`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct AllChecksums {
|
||||||
|
pub crc32: String,
|
||||||
|
pub md5: String,
|
||||||
|
pub sha1: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same job as calling [`compute_checksum`] three times (once per [`ChecksumKind`]),
|
||||||
|
/// but streams the file exactly once instead of three times - used for the naming-scheme
|
||||||
|
/// `{crc32}`/`{md5}`/`{sha1}` fields (see `src-tauri/src/sfv.rs`'s `compute_naming_hashes`),
|
||||||
|
/// where a scheme referencing more than one hash field would otherwise hash the same
|
||||||
|
/// (often multi-gigabyte video) file redundantly. The SFV panel itself still calls
|
||||||
|
/// [`compute_checksum`] directly - it only ever needs one algorithm at a time, picked by
|
||||||
|
/// the user, so tripling its I/O for the other two nobody asked for would be wasteful in
|
||||||
|
/// the other direction.
|
||||||
|
pub fn compute_all_checksums(path: &Path) -> io::Result<AllChecksums> {
|
||||||
|
let mut file = File::open(path)?;
|
||||||
|
let mut buf = [0u8; READ_BUFFER_SIZE];
|
||||||
|
|
||||||
|
let mut crc32 = crc32fast::Hasher::new();
|
||||||
|
let mut md5 = Md5::new();
|
||||||
|
let mut sha1 = Sha1::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let n = file.read(&mut buf)?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let chunk = &buf[..n];
|
||||||
|
crc32.update(chunk);
|
||||||
|
md5.update(chunk);
|
||||||
|
sha1.update(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(AllChecksums {
|
||||||
|
crc32: format!("{:08X}", crc32.finalize()),
|
||||||
|
md5: format!("{:x}", md5.finalize()),
|
||||||
|
sha1: format!("{:x}", sha1.finalize()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// One entry parsed out of an existing checksum file: the file name it references
|
/// 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,
|
/// (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
|
/// 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());
|
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]
|
#[test]
|
||||||
fn parses_a_typical_sfv_file_skipping_comments_and_blank_lines() {
|
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";
|
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();
|
let err = ExpressionFormat::compile("{show name (}").unwrap_err();
|
||||||
assert!(err.to_string().contains("syntax error"), "message was: {err}");
|
assert!(err.to_string().contains("syntax error"), "message was: {err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scripting_helpers_pad_and_sort_name_work_in_a_naming_scheme() {
|
||||||
|
// A realistic naming scheme leaning on the newly-ported scripting/naming helpers:
|
||||||
|
// zero-padded season/episode numbers plus a sort-friendly show name for a "by title"
|
||||||
|
// library layout.
|
||||||
|
let fmt = ExpressionFormat::compile("{n.sort_name()}/S{s.pad(2)}E{e.pad(2)} - {t}").unwrap();
|
||||||
|
let bindings = string_bindings([
|
||||||
|
("n", "The Office"),
|
||||||
|
("s", "5"),
|
||||||
|
("e", "14"),
|
||||||
|
("t", "Stress Relief"),
|
||||||
|
]);
|
||||||
|
assert_eq!(fmt.format(&bindings).unwrap(), "Office, The/S05E14 - Stress Relief");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scripting_helper_validate_file_name_sanitizes_a_title_with_illegal_characters() {
|
||||||
|
let fmt = ExpressionFormat::compile("{t.colon().validate_file_name()}").unwrap();
|
||||||
|
let bindings = string_bindings([("t", "Aliens: Special Edition?")]);
|
||||||
|
assert_eq!(fmt.format(&bindings).unwrap(), "Aliens - Special Edition");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scripting_helper_acronym_and_space_with_combine_in_an_expression() {
|
||||||
|
let fmt = ExpressionFormat::compile(r#"{n.acronym()}_{n.space_with("_")}"#).unwrap();
|
||||||
|
let bindings = string_bindings([("n", "Attack on Titan")]);
|
||||||
|
assert_eq!(fmt.format(&bindings).unwrap(), "AOT_Attack_on_Titan");
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,8 +46,9 @@ Options:
|
|||||||
movies are also looked up online when this is tmdb or omdb - TVmaze has no movie catalog)
|
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)
|
--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)
|
--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
|
-non-strict Accepted for compatibility with existing rename scripts; has no effect (local
|
||||||
confidence score to loosen - see the aurora_curator_core::cli module doc)
|
matching has no confidence score to loosen - see the aurora_curator_core::cli
|
||||||
|
module doc)
|
||||||
-h, --help Show this help and exit
|
-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
|
A --db tmdb/omdb source needs the matching API token/key already saved through the app's own Fetch Data
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ pub struct EpisodeRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(tag = "status", rename_all = "camelCase")]
|
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||||
pub enum EpisodeListOutcome {
|
pub enum EpisodeListOutcome {
|
||||||
Found {
|
Found {
|
||||||
series_name: String,
|
series_name: String,
|
||||||
@@ -90,4 +90,20 @@ mod tests {
|
|||||||
assert_eq!(row.title, "Ozymandias");
|
assert_eq!(row.title, "Ozymandias");
|
||||||
assert_eq!(row.air_date.as_deref(), Some("2013-09-15"));
|
assert_eq!(row.air_date.as_deref(), Some("2013-09-15"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `#[serde(tag = "status", rename_all = "camelCase")]` alone only renames the tag's
|
||||||
|
// own values, not the field names inside a struct-like variant - see
|
||||||
|
// `metadata.rs`'s `serde_wire_format` tests for the full story and the bug this
|
||||||
|
// caught. `rename_all_fields = "camelCase"` on this enum is what actually renames
|
||||||
|
// `series_name` to `seriesName` on the wire; this test pins that.
|
||||||
|
#[test]
|
||||||
|
fn episode_list_outcome_found_serializes_with_camel_case_field_names() {
|
||||||
|
let outcome = EpisodeListOutcome::Found {
|
||||||
|
series_name: "Breaking Bad".to_string(),
|
||||||
|
episodes: vec![],
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&outcome).unwrap();
|
||||||
|
assert!(json.contains("\"seriesName\":\"Breaking Bad\""), "got: {json}");
|
||||||
|
assert!(!json.contains("series_name"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,12 +119,19 @@ pub fn run() {
|
|||||||
settings::get_opensubtitles_username,
|
settings::get_opensubtitles_username,
|
||||||
settings::has_opensubtitles_password,
|
settings::has_opensubtitles_password,
|
||||||
settings::save_opensubtitles_login,
|
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::search_subtitles,
|
||||||
subtitles::download_subtitle,
|
subtitles::download_subtitle,
|
||||||
episodes::browse_episodes,
|
episodes::browse_episodes,
|
||||||
sfv::compute_checksums,
|
sfv::compute_checksums,
|
||||||
sfv::save_checksum_file,
|
sfv::save_checksum_file,
|
||||||
sfv::verify_checksum_file,
|
sfv::verify_checksum_file,
|
||||||
|
sfv::compute_naming_hashes,
|
||||||
list::export_text_list,
|
list::export_text_list,
|
||||||
probe::probe_media_files,
|
probe::probe_media_files,
|
||||||
history::list_rename_history,
|
history::list_rename_history,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ pub struct FetchQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(tag = "status", rename_all = "camelCase")]
|
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||||
pub enum FetchOutcome {
|
pub enum FetchOutcome {
|
||||||
Found {
|
Found {
|
||||||
series_name: String,
|
series_name: String,
|
||||||
@@ -298,7 +298,7 @@ pub struct MovieFetchQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(tag = "status", rename_all = "camelCase")]
|
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||||
pub enum MovieFetchOutcome {
|
pub enum MovieFetchOutcome {
|
||||||
Found {
|
Found {
|
||||||
title: String,
|
title: String,
|
||||||
@@ -371,3 +371,45 @@ async fn run_movie_fetch(client: AnyMovieClient, items: Vec<MovieFetchQuery>) ->
|
|||||||
|
|
||||||
results
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod serde_wire_format {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// `#[serde(tag = "status", rename_all = "camelCase")]` on an enum only renames the
|
||||||
|
// tag's own values (`Found` -> "found") - NOT the field names inside a struct-like
|
||||||
|
// variant, which is a genuinely easy thing to miss since a plain (non-tagged)
|
||||||
|
// `#[serde(rename_all = "camelCase")]` struct DOES rename its fields. Without the
|
||||||
|
// separate `rename_all_fields = "camelCase"` this crate now also carries, `Found {
|
||||||
|
// series_name, episode_title, air_date }` serialized as literal `series_name`/
|
||||||
|
// `episode_title`/`air_date` keys - the frontend's `const { seriesName, episodeTitle
|
||||||
|
// } = tvResult.outcome` destructured `undefined` for both, which then silently
|
||||||
|
// vanished (JSON.stringify drops `undefined` properties) by the time bindings
|
||||||
|
// reached `render_expression`, rendering `{show name}`/`{episode name}` as empty
|
||||||
|
// even for entries the status line correctly reported as "found". These two tests
|
||||||
|
// pin the fix so the same enum shape can't quietly regress it.
|
||||||
|
#[test]
|
||||||
|
fn fetch_outcome_found_serializes_with_camel_case_field_names() {
|
||||||
|
let outcome = FetchOutcome::Found {
|
||||||
|
series_name: "Fire Country".to_string(),
|
||||||
|
episode_title: "Pilot".to_string(),
|
||||||
|
air_date: Some("2024-01-01".to_string()),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&outcome).unwrap();
|
||||||
|
assert!(json.contains("\"seriesName\":\"Fire Country\""), "got: {json}");
|
||||||
|
assert!(json.contains("\"episodeTitle\":\"Pilot\""), "got: {json}");
|
||||||
|
assert!(json.contains("\"airDate\":\"2024-01-01\""), "got: {json}");
|
||||||
|
assert!(!json.contains("series_name") && !json.contains("episode_title") && !json.contains("air_date"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn movie_fetch_outcome_found_serializes_with_camel_case_field_names() {
|
||||||
|
let outcome = MovieFetchOutcome::Found {
|
||||||
|
title: "Inception".to_string(),
|
||||||
|
release_date: Some("2010-07-16".to_string()),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&outcome).unwrap();
|
||||||
|
assert!(json.contains("\"releaseDate\":\"2010-07-16\""), "got: {json}");
|
||||||
|
assert!(!json.contains("release_date"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,6 +83,32 @@ struct StoredSettings {
|
|||||||
/// [`has_opensubtitles_password`], which only reports whether one is set.
|
/// [`has_opensubtitles_password`], which only reports whether one is set.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
opensubtitles_password: Option<String>,
|
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> {
|
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)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -372,6 +467,76 @@ mod tests {
|
|||||||
assert_eq!(parsed.opensubtitles_password, None);
|
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]
|
#[test]
|
||||||
fn merge_login_update_keeps_the_other_field_when_one_is_blank() {
|
fn merge_login_update_keeps_the_other_field_when_one_is_blank() {
|
||||||
// Updating just the password shouldn't silently clear a previously-saved
|
// Updating just the password shouldn't silently clear a previously-saved
|
||||||
|
|||||||
@@ -53,6 +53,51 @@ pub async fn compute_checksums(paths: Vec<String>, kind: ChecksumKind) -> Vec<Ch
|
|||||||
.unwrap_or_default()
|
.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
|
/// 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.
|
/// input shape `save_checksum_file` builds a sidecar file's text content from.
|
||||||
#[derive(Debug, Clone, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
|||||||
+17
-1
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import Sidebar, { PANELS } from "./components/Sidebar";
|
import Sidebar, { PANELS } from "./components/Sidebar";
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
import RenamePanel from "./components/RenamePanel";
|
import RenamePanel from "./components/RenamePanel";
|
||||||
@@ -8,11 +9,26 @@ import SfvPanel from "./components/SfvPanel";
|
|||||||
import FilterPanel from "./components/FilterPanel";
|
import FilterPanel from "./components/FilterPanel";
|
||||||
import ListPanel from "./components/ListPanel";
|
import ListPanel from "./components/ListPanel";
|
||||||
import PanelStub from "./components/PanelStub";
|
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() {
|
export default function App() {
|
||||||
const [selected, setSelected] = useState(PANELS[0].id);
|
const [selected, setSelected] = useState(PANELS[0].id);
|
||||||
const current = PANELS.find((p) => p.id === selected) ?? PANELS[0];
|
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 (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<Sidebar selected={selected} onSelect={setSelected} />
|
<Sidebar selected={selected} onSelect={setSelected} />
|
||||||
|
|||||||
@@ -29,6 +29,32 @@ interface ReleaseInfo {
|
|||||||
group: string | null;
|
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";
|
type MediaKind = "tv" | "movie" | "unmatched";
|
||||||
|
|
||||||
interface FileInfo {
|
interface FileInfo {
|
||||||
@@ -39,6 +65,12 @@ interface FileInfo {
|
|||||||
movieName?: string;
|
movieName?: string;
|
||||||
year?: number | null;
|
year?: number | null;
|
||||||
release: ReleaseInfo | 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> = {
|
const KIND_LABELS: Record<MediaKind, string> = {
|
||||||
@@ -57,14 +89,15 @@ function displayName(file: FileEntry): string {
|
|||||||
|
|
||||||
function subtitleFor(info: FileInfo | undefined): string {
|
function subtitleFor(info: FileInfo | undefined): string {
|
||||||
if (!info) return "";
|
if (!info) return "";
|
||||||
|
const tag = info.resolution ? ` · ${info.resolution}` : "";
|
||||||
if (info.kind === "tv") {
|
if (info.kind === "tv") {
|
||||||
const se = `S${String(info.season ?? 0).padStart(2, "0")}E${String(info.episode ?? 0).padStart(2, "0")}`;
|
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") {
|
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() {
|
export default function FilterPanel() {
|
||||||
@@ -73,6 +106,8 @@ export default function FilterPanel() {
|
|||||||
const [scanning, setScanning] = useState(false);
|
const [scanning, setScanning] = useState(false);
|
||||||
const [extensions, setExtensions] = useState<Set<string>>(new Set());
|
const [extensions, setExtensions] = useState<Set<string>>(new Set());
|
||||||
const [kinds, setKinds] = useState<Set<MediaKind>>(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 [nameContains, setNameContains] = useState("");
|
||||||
const [season, setSeason] = useState("");
|
const [season, setSeason] = useState("");
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
@@ -91,6 +126,13 @@ export default function FilterPanel() {
|
|||||||
async function scan(targets: FileEntry[]) {
|
async function scan(targets: FileEntry[]) {
|
||||||
setScanning(true);
|
setScanning(true);
|
||||||
try {
|
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(
|
const results = await Promise.all(
|
||||||
targets.map(async (file): Promise<readonly [string, FileInfo]> => {
|
targets.map(async (file): Promise<readonly [string, FileInfo]> => {
|
||||||
const releasePromise = invoke<ReleaseInfo | null>("guess_release_info", {
|
const releasePromise = invoke<ReleaseInfo | null>("guess_release_info", {
|
||||||
@@ -107,6 +149,9 @@ export default function FilterPanel() {
|
|||||||
season: episode.season,
|
season: episode.season,
|
||||||
episode: episode.episode,
|
episode: episode.episode,
|
||||||
release: null,
|
release: null,
|
||||||
|
resolution: null,
|
||||||
|
videoCodec: null,
|
||||||
|
probed: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -116,31 +161,61 @@ export default function FilterPanel() {
|
|||||||
try {
|
try {
|
||||||
const movie = await invoke<MovieGuess | null>("guess_movie", { filename: displayName(file) });
|
const movie = await invoke<MovieGuess | null>("guess_movie", { filename: displayName(file) });
|
||||||
if (movie) {
|
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 {
|
} catch {
|
||||||
// Neither guess worked.
|
// 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;
|
fileInfo.release = await releasePromise;
|
||||||
return [file.path, fileInfo] as const;
|
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 nextInfo: Record<string, FileInfo> = {};
|
||||||
const seenExt = new Set<string>();
|
const seenExt = new Set<string>();
|
||||||
const seenKinds = new Set<MediaKind>();
|
const seenKinds = new Set<MediaKind>();
|
||||||
|
const seenRes = new Set<string>();
|
||||||
|
const seenCodec = new Set<string>();
|
||||||
|
let probedCount = 0;
|
||||||
for (const [path, fileInfo] of results) {
|
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;
|
nextInfo[path] = fileInfo;
|
||||||
seenKinds.add(fileInfo.kind);
|
seenKinds.add(fileInfo.kind);
|
||||||
|
seenRes.add(fileInfo.resolution ?? UNKNOWN);
|
||||||
|
seenCodec.add(fileInfo.videoCodec ?? UNKNOWN);
|
||||||
}
|
}
|
||||||
for (const file of targets) seenExt.add(file.ext);
|
for (const file of targets) seenExt.add(file.ext);
|
||||||
|
|
||||||
setInfo(nextInfo);
|
setInfo(nextInfo);
|
||||||
setExtensions(seenExt);
|
setExtensions(seenExt);
|
||||||
setKinds(seenKinds);
|
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 {
|
} finally {
|
||||||
setScanning(false);
|
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 allExtensions = Array.from(new Set(files.map((f) => f.ext))).sort();
|
||||||
const allKinds = (["tv", "movie", "unmatched"] as MediaKind[]).filter((k) =>
|
const allKinds = (["tv", "movie", "unmatched"] as MediaKind[]).filter((k) =>
|
||||||
Object.values(info).some((i) => i.kind === 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;
|
const seasonFilter = kinds.has("tv") && season.trim() !== "" ? Number(season) : null;
|
||||||
|
|
||||||
@@ -180,6 +278,8 @@ export default function FilterPanel() {
|
|||||||
if (!fileInfo) return false;
|
if (!fileInfo) return false;
|
||||||
if (!extensions.has(file.ext)) return false;
|
if (!extensions.has(file.ext)) return false;
|
||||||
if (!kinds.has(fileInfo.kind)) 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())) {
|
if (nameContains.trim() && !displayName(file).toLowerCase().includes(nameContains.trim().toLowerCase())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -244,6 +344,40 @@ export default function FilterPanel() {
|
|||||||
</div>
|
</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">
|
<div className="filter-criteria-row">
|
||||||
<span className="filter-criteria-label">Filename contains</span>
|
<span className="filter-criteria-label">Filename contains</span>
|
||||||
<input
|
<input
|
||||||
@@ -272,7 +406,12 @@ export default function FilterPanel() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="filter-results">
|
<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 && (
|
{files.length > 0 && (
|
||||||
<div className="filter-results-header">
|
<div className="filter-results-header">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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);
|
border-bottom: 1px solid var(--dock-line);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,3 +153,26 @@ export function KeyIcon() {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function HashIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...{ ...base, width: 15, height: 15 }}>
|
||||||
|
<path d="M9 3.5L6.5 20.5M17.5 3.5L15 20.5M4 8.5h16M3 15.5h16" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used for the app-wide Settings entry (theme picker) in the sidebar footer -
|
||||||
|
// deliberately a palette rather than reusing GearIcon, which already means something
|
||||||
|
// different (the per-file Naming Scheme editor's trigger button in the Rename panel).
|
||||||
|
export function PaletteIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...{ ...base, width: 16, height: 16 }}>
|
||||||
|
<path d="M12 3.5c-4.7 0-8.5 3.8-8.5 8.5s3.8 8.5 8.5 8.5c1 0 1.7-.8 1.7-1.7 0-.45-.17-.86-.46-1.16-.29-.31-.46-.72-.46-1.17 0-.94.76-1.7 1.7-1.7h2c2.6 0 4.7-2.1 4.7-4.7 0-4.15-4.1-7.55-9.19-7.55Z" />
|
||||||
|
<circle cx="7.7" cy="11" r="1.15" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="10.2" cy="7.3" r="1.15" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="14.4" cy="7.6" r="1.15" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="16.6" cy="11.2" r="1.15" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,31 @@ interface ReleaseInfo {
|
|||||||
group: string | null;
|
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
|
// 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
|
// `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
|
// 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>>) {
|
async function scan(targets: FileEntry[], base: Record<string, Record<string, string>>) {
|
||||||
setScanning(true);
|
setScanning(true);
|
||||||
try {
|
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(
|
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", {
|
const releasePromise = invoke<ReleaseInfo | null>("guess_release_info", {
|
||||||
filename: displayName(file),
|
filename: displayName(file),
|
||||||
}).catch(() => null);
|
}).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>> = {};
|
const next: Record<string, Record<string, string>> = {};
|
||||||
let matchedCount = 0;
|
let matchedCount = 0;
|
||||||
for (const [path, b] of results) {
|
let probedCount = 0;
|
||||||
next[path] = b;
|
for (const [path, b, release] of results) {
|
||||||
if (b.n) matchedCount += 1;
|
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);
|
setBindings(next);
|
||||||
setStatus(
|
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 {
|
} finally {
|
||||||
setScanning(false);
|
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;{" "}
|
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>{"{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 -
|
<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>
|
||||||
|
|
||||||
<div className="list-results">
|
<div className="list-results">
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-row:hover {
|
.list-row:hover {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--tint-03);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-row.selected {
|
.list-row.selected {
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--muted-text);
|
color: var(--muted-text);
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: var(--tint-05);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
}
|
}
|
||||||
@@ -114,17 +114,17 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--surface-deep);
|
border: 1px solid var(--surface-deep);
|
||||||
background: #172a31;
|
background: var(--btn-bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-icon-button:hover {
|
.list-icon-button:hover {
|
||||||
background: #203941;
|
background: var(--btn-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-icon-button:active {
|
.list-icon-button:active {
|
||||||
background: #28515a;
|
background: var(--btn-bg-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-icon-button:disabled {
|
.list-icon-button:disabled {
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
background: #16262d;
|
background: var(--popup-bg);
|
||||||
border: 1px solid var(--surface-deep);
|
border: 1px solid var(--surface-deep);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
@@ -197,7 +197,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--surface-deep);
|
border: 1px solid var(--surface-deep);
|
||||||
background: #172a31;
|
background: var(--btn-bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -206,7 +206,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action-button:hover {
|
.action-button:hover {
|
||||||
background: #203941;
|
background: var(--btn-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-button.primary {
|
.action-button.primary {
|
||||||
@@ -217,7 +217,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action-button.primary:hover {
|
.action-button.primary:hover {
|
||||||
background: #70e3bb;
|
background: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.format-editor {
|
.format-editor {
|
||||||
@@ -262,14 +262,20 @@
|
|||||||
line-height: 1.5;
|
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-hint code,
|
||||||
.format-editor label 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-family: ui-monospace, "SF Mono", monospace;
|
||||||
font-size: 10.5px;
|
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 {
|
.format-hint a {
|
||||||
@@ -306,13 +312,26 @@
|
|||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.format-fields-group code {
|
.format-field-chip {
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: var(--tint-06);
|
||||||
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
font-family: ui-monospace, "SF Mono", monospace;
|
font-family: ui-monospace, "SF Mono", monospace;
|
||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
color: var(--aurora-green);
|
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 {
|
.modal-overlay {
|
||||||
@@ -376,7 +395,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-plan-row:hover {
|
.modal-plan-row:hover {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--tint-03);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-plan-old {
|
.modal-plan-old {
|
||||||
@@ -418,7 +437,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-plan-remove:hover {
|
.modal-plan-remove:hover {
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: var(--tint-06);
|
||||||
color: #ff8a8a;
|
color: #ff8a8a;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,7 +564,7 @@
|
|||||||
.history-batch {
|
.history-batch {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: var(--tint-02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-batch-header {
|
.history-batch-header {
|
||||||
@@ -570,7 +589,7 @@
|
|||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--muted-text);
|
color: var(--muted-text);
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: var(--tint-05);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
}
|
}
|
||||||
@@ -599,7 +618,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preset-row:hover {
|
.preset-row:hover {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--tint-03);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-slot-badge {
|
.preset-slot-badge {
|
||||||
@@ -610,7 +629,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: var(--tint-06);
|
||||||
color: var(--aurora-green);
|
color: var(--aurora-green);
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -633,7 +652,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preset-expression:hover {
|
.preset-expression:hover {
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: var(--tint-06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-expression:disabled {
|
.preset-expression:disabled {
|
||||||
|
|||||||
+434
-61
@@ -5,6 +5,7 @@ import {
|
|||||||
DownIcon,
|
DownIcon,
|
||||||
FetchIcon,
|
FetchIcon,
|
||||||
GearIcon,
|
GearIcon,
|
||||||
|
HashIcon,
|
||||||
HistoryIcon,
|
HistoryIcon,
|
||||||
KeyIcon,
|
KeyIcon,
|
||||||
LoadIcon,
|
LoadIcon,
|
||||||
@@ -91,6 +92,19 @@ interface ProbeOutcome {
|
|||||||
probe: MediaProbe;
|
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 {
|
function pad2(n: number): string {
|
||||||
return n.toString().padStart(2, "0");
|
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.
|
// What Match + Fetch Data together can fill in.
|
||||||
const FETCHED_EXPRESSION = "{show name} - {season number}x{episode number} - {episode name}";
|
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,
|
// Copy for each RenameAction, used in the confirmation modal's segmented selector,
|
||||||
// subtitle, confirm-button label, and the post-confirm status message.
|
// subtitle, confirm-button label, and the post-confirm status message.
|
||||||
const RENAME_ACTION_LABELS: Record<
|
const RENAME_ACTION_LABELS: Record<
|
||||||
@@ -452,6 +476,20 @@ function LoadMenuButton({ onFiles, onFolder }: { onFiles: () => void; onFolder:
|
|||||||
|
|
||||||
export default function RenamePanel() {
|
export default function RenamePanel() {
|
||||||
const [expression, setExpression] = useState(SAMPLE_EXPRESSION);
|
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 [editingFormat, setEditingFormat] = useState(false);
|
||||||
const [items, setItems] = useState<Item[]>(SAMPLE_ITEMS);
|
const [items, setItems] = useState<Item[]>(SAMPLE_ITEMS);
|
||||||
const [selected, setSelected] = useState<string | null>(SAMPLE_ITEMS[0].id);
|
const [selected, setSelected] = useState<string | null>(SAMPLE_ITEMS[0].id);
|
||||||
@@ -489,6 +527,17 @@ export default function RenamePanel() {
|
|||||||
const [omdbKeyInput, setOmdbKeyInput] = useState("");
|
const [omdbKeyInput, setOmdbKeyInput] = useState("");
|
||||||
const [savingSettings, setSavingSettings] = useState(false);
|
const [savingSettings, setSavingSettings] = useState(false);
|
||||||
const [fetching, setFetching] = 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
|
// Rename history / undo. `null` batches = not loaded yet (also used to show a
|
||||||
// loading state the first time the History panel is opened).
|
// loading state the first time the History panel is opened).
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
@@ -543,14 +592,86 @@ export default function RenamePanel() {
|
|||||||
expressionRef.current = expression;
|
expressionRef.current = expression;
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
Promise.all(
|
Promise.all(
|
||||||
items.map(async (item) => {
|
items.map(async (item) => {
|
||||||
if (!item.bindings) return [item.id, null] as const;
|
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", {
|
const rendered = await invoke<string>("render_expression", {
|
||||||
expression,
|
expression: activeExpr,
|
||||||
bindings: item.bindings,
|
bindings: item.bindings,
|
||||||
});
|
});
|
||||||
return [item.id, `${rendered}${item.ext ? `.${item.ext}` : ""}`] as const;
|
return [item.id, `${rendered}${item.ext ? `.${item.ext}` : ""}`] as const;
|
||||||
@@ -573,7 +694,7 @@ export default function RenamePanel() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [expression, items]);
|
}, [expression, movieExpression, items]);
|
||||||
|
|
||||||
const notImplemented = (feature: string) => {
|
const notImplemented = (feature: string) => {
|
||||||
setStatus(`${feature} isn't implemented yet — see the porting roadmap in the README.`);
|
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;
|
if (entries.length === 0) return;
|
||||||
|
|
||||||
// Only auto-switch the naming scheme the first time real files replace the demo,
|
// Only auto-switch the naming scheme the first time real files replace the demo,
|
||||||
// and only if the user hasn't already customized it.
|
// and only if the user hasn't already customized it (including a scheme restored
|
||||||
if (expressionRef.current === SAMPLE_EXPRESSION) {
|
// from a previous session - see restoredCustomSchemeRef above).
|
||||||
|
if (expressionRef.current === SAMPLE_EXPRESSION && !restoredCustomSchemeRef.current) {
|
||||||
setExpression(REAL_FILE_EXPRESSION);
|
setExpression(REAL_FILE_EXPRESSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,13 +776,23 @@ export default function RenamePanel() {
|
|||||||
// batched `probe_media_files` command) for resolution/duration/codecs read straight
|
// 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
|
// from its container header - see the bindings merge below for how a probed value
|
||||||
// and a filename-tag value combine.
|
// 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);
|
const targets = items.filter((i) => !i.isSample);
|
||||||
if (targets.length === 0) {
|
if (targets.length === 0) {
|
||||||
setStatus("Load some files first, then Match will scan their names for season/episode or movie info.");
|
setStatus("Load some files first, then Match will scan their names for season/episode or movie info.");
|
||||||
window.clearTimeout((runMatching as any)._t);
|
window.clearTimeout((runMatching as any)._t);
|
||||||
(runMatching as any)._t = window.setTimeout(() => setStatus(null), 3200);
|
(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
|
// One batched round trip for real probing of every target, run alongside the
|
||||||
@@ -712,8 +844,12 @@ export default function RenamePanel() {
|
|||||||
const tagged = results.filter(([, , release]) => release !== null).length;
|
const tagged = results.filter(([, , release]) => release !== null).length;
|
||||||
const probed = [...probes.values()].filter((probe) => probe.container !== null).length;
|
const probed = [...probes.values()].filter((probe) => probe.container !== null).length;
|
||||||
|
|
||||||
setItems((prev) =>
|
// A plain `items.map(...)` rather than the usual `setItems((prev) => prev.map(...))`
|
||||||
prev.map((item) => {
|
// 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 guess = guesses.get(item.id);
|
||||||
const release = releaseGuesses.get(item.id);
|
const release = releaseGuesses.get(item.id);
|
||||||
const probe = probes.get(item.id);
|
const probe = probes.get(item.id);
|
||||||
@@ -801,14 +937,16 @@ export default function RenamePanel() {
|
|||||||
...releaseBindings,
|
...releaseBindings,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
});
|
||||||
);
|
setItems(nextItems);
|
||||||
|
|
||||||
// Showcase the newly-populated fields, but only the first time and only if the
|
// 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
|
// 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
|
// default (including a scheme restored from a previous session - see
|
||||||
// expression" heuristic doesn't generalize across a batch mixing TV and movies.
|
// restoredCustomSchemeRef above). Movies deliberately don't drive this
|
||||||
if (expressionRef.current === REAL_FILE_EXPRESSION && matchedTv > 0) {
|
// 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);
|
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).`
|
? `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."
|
: "No season/episode or movie title recognized in the loaded filenames."
|
||||||
);
|
);
|
||||||
|
if (!opts?.chained) {
|
||||||
window.clearTimeout((runMatching as any)._t);
|
window.clearTimeout((runMatching as any)._t);
|
||||||
(runMatching as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
(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
|
// 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
|
// 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,
|
// use different sources for each half. Both run via separate Tauri commands,
|
||||||
// concurrently, and merge back into a single set of counts/status message.
|
// 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
|
(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;
|
const targetCount = tvTargets.length + movieTargets.length;
|
||||||
|
|
||||||
if (targetCount === 0) {
|
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.");
|
setStatus("Run Match first — Fetch Data needs a guessed series/episode or movie title to look up.");
|
||||||
window.clearTimeout((runFetch as any)._t);
|
window.clearTimeout((runFetch as any)._t);
|
||||||
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
(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
|
// 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,
|
needsOmdbKey && hasOmdbKey === false ? "a free OMDb API key" : null,
|
||||||
].filter((m): m is string => m !== null);
|
].filter((m): m is string => m !== null);
|
||||||
setStatus(`Add ${missing.join(" and ")} in Settings first, then Fetch Data will work.`);
|
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);
|
window.clearTimeout((runFetch as any)._t);
|
||||||
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFetching(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
|
// 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.
|
// hasn't already customized the naming scheme away from the Match-set default
|
||||||
if (expressionRef.current === MATCHED_EXPRESSION && found > 0) {
|
// (including a scheme restored from a previous session - see
|
||||||
|
// restoredCustomSchemeRef above).
|
||||||
|
if (expressionRef.current === MATCHED_EXPRESSION && found > 0 && !restoredCustomSchemeRef.current) {
|
||||||
setExpression(FETCHED_EXPRESSION);
|
setExpression(FETCHED_EXPRESSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1267,6 +1425,97 @@ export default function RenamePanel() {
|
|||||||
window.clearTimeout((runFetch as any)._t);
|
window.clearTimeout((runFetch as any)._t);
|
||||||
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 5000);
|
(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 = () => {
|
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
|
// 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
|
// 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.
|
// `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 applyPreset = (slot: string) => {
|
||||||
const value = presets?.[slot];
|
const value = presets?.[slot];
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
setExpression(value);
|
setActiveExpression(value);
|
||||||
setStatus(`Applied preset ${slot}.`);
|
setStatus(`Applied preset ${slot}.`);
|
||||||
window.clearTimeout((applyPreset as any)._t);
|
window.clearTimeout((applyPreset as any)._t);
|
||||||
(applyPreset as any)._t = window.setTimeout(() => setStatus(null), 2500);
|
(applyPreset as any)._t = window.setTimeout(() => setStatus(null), 2500);
|
||||||
@@ -1333,8 +1589,8 @@ export default function RenamePanel() {
|
|||||||
const saveCurrentAsPreset = async (slot: string) => {
|
const saveCurrentAsPreset = async (slot: string) => {
|
||||||
setPresetsBusySlot(slot);
|
setPresetsBusySlot(slot);
|
||||||
try {
|
try {
|
||||||
await invoke("save_preset", { slot, expression });
|
await invoke("save_preset", { slot, expression: activeExpression });
|
||||||
setPresets((prev) => ({ ...(prev ?? {}), [slot]: expression }));
|
setPresets((prev) => ({ ...(prev ?? {}), [slot]: activeExpression }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(`Couldn't save preset ${slot}: ${err}`);
|
setStatus(`Couldn't save preset ${slot}: ${err}`);
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<div className="rename-panel">
|
<div className="rename-panel">
|
||||||
<div className="rename-lists">
|
<div className="rename-lists">
|
||||||
@@ -1428,8 +1725,13 @@ export default function RenamePanel() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="rename-actions">
|
<div className="rename-actions">
|
||||||
<button className="action-button" onClick={runMatching}>
|
<button
|
||||||
Match
|
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>
|
||||||
<button className="action-button primary" onClick={prepareRename}>
|
<button className="action-button primary" onClick={prepareRename}>
|
||||||
Rename
|
Rename
|
||||||
@@ -1447,7 +1749,18 @@ export default function RenamePanel() {
|
|||||||
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => moveSelected("up")} />
|
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => moveSelected("up")} />
|
||||||
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
|
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
|
||||||
<span className="list-button-gap" />
|
<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={<KeyIcon />} label="Fetch Data Settings" onClick={openSettings} />
|
||||||
<ListButton icon={<GearIcon />} label="Naming Scheme" onClick={() => setEditingFormat((v) => !v)} />
|
<ListButton icon={<GearIcon />} label="Naming Scheme" onClick={() => setEditingFormat((v) => !v)} />
|
||||||
</>
|
</>
|
||||||
@@ -1457,16 +1770,37 @@ export default function RenamePanel() {
|
|||||||
|
|
||||||
{editingFormat && (
|
{editingFormat && (
|
||||||
<div className="format-editor">
|
<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
|
<input
|
||||||
id="expression"
|
id="expression"
|
||||||
value={expression}
|
ref={expressionInputRef}
|
||||||
|
value={activeExpression}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
onChange={(e) => setExpression(e.target.value)}
|
onChange={(e) => setActiveExpression(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{computeError && <p className="format-error">{computeError}</p>}
|
{computeError && <p className="format-error">{computeError}</p>}
|
||||||
<p className="format-hint">
|
<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{" "}
|
<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
|
{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
|
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">
|
<a href="https://rhai.rs/" target="_blank" rel="noreferrer">
|
||||||
Rhai
|
Rhai
|
||||||
</a>{" "}
|
</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>
|
</p>
|
||||||
<div className="format-fields">
|
<div className="format-fields">
|
||||||
<div className="format-fields-group">
|
<div className="format-fields-group">
|
||||||
<span className="format-fields-group-title">Always available</span>
|
<span className="format-fields-group-title">Always available</span>
|
||||||
<code>{"{name}"}</code>
|
<FieldChip token="{name}" />
|
||||||
<code>{"{ext}"}</code>
|
<FieldChip token="{ext}" />
|
||||||
</div>
|
</div>
|
||||||
|
{editingKind === "tv" && (
|
||||||
<div className="format-fields-group">
|
<div className="format-fields-group">
|
||||||
<span className="format-fields-group-title">
|
<span className="format-fields-group-title">
|
||||||
TV, after <strong>Match</strong> (local, offline)
|
TV, after <strong>Match</strong> (local, offline)
|
||||||
</span>
|
</span>
|
||||||
<code>{"{show name}"}</code>
|
<FieldChip token="{show name}" />
|
||||||
<code>{"{season number}"}</code>
|
<FieldChip token="{season number}" />
|
||||||
<code>{"{episode number}"}</code>
|
<FieldChip token="{episode number}" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{editingKind === "tv" && (
|
||||||
<div className="format-fields-group">
|
<div className="format-fields-group">
|
||||||
<span className="format-fields-group-title">
|
<span className="format-fields-group-title">
|
||||||
TV, after <strong>Fetch Data</strong> (real TMDB metadata)
|
TV, after <strong>Fetch Data</strong> (real TMDB metadata)
|
||||||
</span>
|
</span>
|
||||||
<code>{"{episode name}"}</code>
|
<FieldChip token="{episode name}" />
|
||||||
<code>{"{airdate}"}</code>
|
<FieldChip token="{airdate}" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{editingKind === "movie" && (
|
||||||
<div className="format-fields-group">
|
<div className="format-fields-group">
|
||||||
<span className="format-fields-group-title">
|
<span className="format-fields-group-title">
|
||||||
Movies, after <strong>Match</strong> or <strong>Fetch Data</strong>
|
Movies, after <strong>Match</strong> or <strong>Fetch Data</strong>
|
||||||
</span>
|
</span>
|
||||||
<code>{"{movie name}"}</code>
|
<FieldChip token="{movie name}" />
|
||||||
<code>{"{year}"}</code>
|
<FieldChip token="{year}" />
|
||||||
<code>{"{airdate}"}</code>
|
<FieldChip token="{airdate}" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="format-fields-group">
|
<div className="format-fields-group">
|
||||||
<span className="format-fields-group-title">
|
<span className="format-fields-group-title">
|
||||||
Release tags, after <strong>Match</strong> (TV or movies, local, offline)
|
Release tags, after <strong>Match</strong> (TV or movies, local, offline)
|
||||||
</span>
|
</span>
|
||||||
<code>{"{resolution}"}</code>
|
<FieldChip token="{resolution}" />
|
||||||
<code>{"{source}"}</code>
|
<FieldChip token="{source}" />
|
||||||
<code>{"{video codec}"}</code>
|
<FieldChip token="{video codec}" />
|
||||||
<code>{"{audio codec}"}</code>
|
<FieldChip token="{audio codec}" />
|
||||||
<code>{"{release group}"}</code>
|
<FieldChip token="{release group}" />
|
||||||
</div>
|
</div>
|
||||||
<div className="format-fields-group">
|
<div className="format-fields-group">
|
||||||
<span className="format-fields-group-title">
|
<span className="format-fields-group-title">
|
||||||
Real file probing, after <strong>Match</strong> (reads the actual file, local, offline)
|
Real file probing, after <strong>Match</strong> (reads the actual file, local, offline)
|
||||||
</span>
|
</span>
|
||||||
<code>{"{width}"}</code>
|
<FieldChip token="{width}" />
|
||||||
<code>{"{height}"}</code>
|
<FieldChip token="{height}" />
|
||||||
<code>{"{fps}"}</code>
|
<FieldChip token="{fps}" />
|
||||||
<code>{"{duration}"}</code>
|
<FieldChip token="{duration}" />
|
||||||
<code>{"{file size}"}</code>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<p className="format-hint">
|
<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
|
<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>/
|
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
|
<code>{"{fps}"}</code>/<code>{"{duration}"}</code>/<code>{"{file size}"}</code> are only ever available
|
||||||
this way. Fetch Data resolves the
|
this way. <strong>Hash Files</strong> computes real <code>{"{crc32}"}</code>/<code>{"{md5}"}</code>/
|
||||||
season/episode or movie guess against real online metadata for the real title, episode title, and
|
<code>{"{sha1}"}</code> checksums the same way the SFV panel does — deliberately not automatic like the
|
||||||
air/release date. TV episodes can come from TMDB, TVmaze, or OMDb, and movies from TMDB or OMDb — pick
|
rest of Match, since hashing reads a file's entire contents rather than just a small header, so it's its
|
||||||
each independently in <strong>Fetch Data Settings</strong> (TVmaze is free and needs no credential at
|
own separate, explicit step. Clicking <strong>Match</strong> then automatically runs Fetch Data right
|
||||||
all; TMDB and OMDb each need their own free API credential set there first). A batch can
|
after, for anything it
|
||||||
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
|
recognized — no separate click needed. Fetch Data itself resolves the season/episode or movie guess
|
||||||
field names (<code>{"{n}"}</code>, <code>{"{s00e00}"}</code>, <code>{"{t}"}</code>, <code>{"{y}"}</code>,
|
against real online metadata for the real title, episode title, and air/release date. TV episodes can
|
||||||
<code>{"{vf}"}</code>, <code>{"{vc}"}</code>, <code>{"{ac}"}</code>, <code>{"{group}"}</code>, etc.) still
|
come from TMDB, TVmaze, or OMDb, and movies from TMDB or OMDb — pick each independently in{" "}
|
||||||
work too, side by side with the names above.
|
<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>
|
||||||
<p className="format-hint">
|
<p className="format-hint">
|
||||||
Use <code>{"/"}</code> in the scheme to organize files into subfolders as part of renaming, e.g.{" "}
|
Use <code>{"/"}</code> in the scheme to organize files into subfolders as part of renaming, e.g.{" "}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
.theme-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
margin: 4px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--surface-deep);
|
||||||
|
background: var(--surface);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card:hover {
|
||||||
|
border-color: var(--dock-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card.selected {
|
||||||
|
border-color: var(--aurora-green);
|
||||||
|
box-shadow: 0 0 0 1px var(--aurora-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card:disabled {
|
||||||
|
opacity: 0.75;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-swatch {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--surface-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-swatch-chip {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card-name {
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-card-check {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 10px;
|
||||||
|
color: var(--aurora-green);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// App-wide Settings: currently just the color theme, opened from the gear-adjacent
|
||||||
|
// palette icon in the sidebar footer (see Sidebar.tsx). Everything else the app
|
||||||
|
// persists lives in its own panel-specific settings modal (Fetch Data Settings in
|
||||||
|
// RenamePanel/EpisodesPanel, subtitle provider settings in SubtitlesPanel) - this is
|
||||||
|
// the one truly app-wide setting so far, hence its own top-level entry point rather
|
||||||
|
// than living inside one of those panels.
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import "./SettingsModal.css";
|
||||||
|
|
||||||
|
export type ThemeId = "aurora" | "white" | "black" | "ocean" | "sunset";
|
||||||
|
|
||||||
|
export const DEFAULT_THEME: ThemeId = "aurora";
|
||||||
|
|
||||||
|
interface ThemeDef {
|
||||||
|
id: ThemeId;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
// Literal swatch colors (background, primary accent, secondary accent) matching
|
||||||
|
// theme.css's own values for this theme - these can't be CSS variables, since the
|
||||||
|
// whole point is to preview a theme that isn't the currently-active one.
|
||||||
|
swatch: [string, string, string];
|
||||||
|
}
|
||||||
|
|
||||||
|
const THEMES: ThemeDef[] = [
|
||||||
|
{ id: "aurora", name: "Aurora", description: "The original dark teal & green look.", swatch: ["#08252b", "#55d7a9", "#52c7db"] },
|
||||||
|
{ id: "white", name: "White", description: "White background, black text, colorful accents.", swatch: ["#ffffff", "#1f9d74", "#157e9c"] },
|
||||||
|
{ id: "black", name: "Black", description: "True black background, white text, grayscale accents.", swatch: ["#000000", "#e5e5e5", "#bfbfbf"] },
|
||||||
|
{ id: "ocean", name: "Ocean", description: "A cool blue-toned dark theme.", swatch: ["#071c2e", "#4fd0e8", "#5aa9ff"] },
|
||||||
|
{ id: "sunset", name: "Sunset", description: "A warm amber-toned dark theme.", swatch: ["#2b1408", "#f0955a", "#f0b66b"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function isThemeId(value: string): value is ThemeId {
|
||||||
|
return THEMES.some((t) => t.id === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a theme id to the document immediately, so switching feels instant instead
|
||||||
|
/// of waiting on a round trip through Rust. Shared by App.tsx's boot-time load and
|
||||||
|
/// this modal's click handler, so both go through the exact same logic.
|
||||||
|
export function applyTheme(theme: ThemeId) {
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
const [current, setCurrent] = useState<ThemeId>(DEFAULT_THEME);
|
||||||
|
const [saving, setSaving] = useState<ThemeId | null>(null);
|
||||||
|
|
||||||
|
// Keep this modal's own idea of "the active theme" in sync with whatever's really
|
||||||
|
// applied - reads the live `data-theme` attribute rather than re-fetching from disk,
|
||||||
|
// since App.tsx's boot-time effect (or a previous open of this same modal) already
|
||||||
|
// applied it there.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const applied = document.documentElement.dataset.theme;
|
||||||
|
setCurrent(applied && isThemeId(applied) ? applied : DEFAULT_THEME);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const pick = async (theme: ThemeId) => {
|
||||||
|
if (theme === current) return;
|
||||||
|
setSaving(theme);
|
||||||
|
applyTheme(theme);
|
||||||
|
setCurrent(theme);
|
||||||
|
try {
|
||||||
|
await invoke("save_theme", { theme });
|
||||||
|
} catch {
|
||||||
|
// The theme still applied for this session even if saving it failed - not
|
||||||
|
// worth blocking or rolling back the visual change over a disk-write hiccup.
|
||||||
|
} finally {
|
||||||
|
setSaving(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-card modal-card-compact" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2 className="modal-title">Settings</h2>
|
||||||
|
<span className="modal-field-label">Theme</span>
|
||||||
|
<p className="modal-subtitle">Pick a color theme for the whole app. Applies immediately and remembers your choice.</p>
|
||||||
|
<div className="theme-grid">
|
||||||
|
{THEMES.map((theme) => (
|
||||||
|
<button
|
||||||
|
key={theme.id}
|
||||||
|
type="button"
|
||||||
|
className={`theme-card${current === theme.id ? " selected" : ""}`}
|
||||||
|
onClick={() => void pick(theme.id)}
|
||||||
|
disabled={saving !== null}
|
||||||
|
title={theme.description}
|
||||||
|
>
|
||||||
|
<span className="theme-swatch">
|
||||||
|
{theme.swatch.map((color, i) => (
|
||||||
|
<span key={i} className="theme-swatch-chip" style={{ background: color }} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="theme-card-name">{theme.name}</span>
|
||||||
|
{current === theme.id && <span className="theme-card-check">✓</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="action-button primary" onClick={onClose}>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -72,6 +72,25 @@
|
|||||||
padding-bottom: 6px;
|
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 {
|
.sidebar-wordmark {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { EpisodesIcon, FilterIcon, ListIcon, RenameIcon, SfvIcon, SubtitlesIcon } from "./Icons";
|
import { useState } from "react";
|
||||||
|
import { EpisodesIcon, FilterIcon, ListIcon, PaletteIcon, RenameIcon, SfvIcon, SubtitlesIcon } from "./Icons";
|
||||||
|
import SettingsModal from "./SettingsModal";
|
||||||
|
|
||||||
export interface PanelDef {
|
export interface PanelDef {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,6 +27,8 @@ export default function Sidebar({
|
|||||||
selected: string;
|
selected: string;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="sidebar">
|
<nav className="sidebar">
|
||||||
<ul className="sidebar-list">
|
<ul className="sidebar-list">
|
||||||
@@ -47,9 +51,18 @@ export default function Sidebar({
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="sidebar-footer">
|
<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-wordmark">Aurora Curator</span>
|
||||||
<span className="sidebar-version">v{APP_VERSION}</span>
|
<span className="sidebar-version">v{APP_VERSION}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+172
-4
@@ -1,7 +1,13 @@
|
|||||||
/* Aurora Curator's UI theme — ported 1:1 (hex values) from the original FB-Mod fork's
|
/* Aurora Curator's UI theme. The "aurora" theme below is ported 1:1 (hex values) from
|
||||||
AuroraTheme.java / HeaderPanel.java / MainFrame.java so the rewrite looks like a
|
the original FB-Mod fork's AuroraTheme.java / HeaderPanel.java / MainFrame.java so
|
||||||
continuation of that design rather than a random restyle. (The app takes its name,
|
the rewrite looks like a continuation of that design rather than a random restyle
|
||||||
"Aurora Curator", from this same Aurora theme.) */
|
(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 {
|
:root {
|
||||||
--dock: #08252b;
|
--dock: #08252b;
|
||||||
--dock-deep: #051115;
|
--dock-deep: #051115;
|
||||||
@@ -19,10 +25,172 @@
|
|||||||
--sunset: #f0b66b;
|
--sunset: #f0b66b;
|
||||||
--list-selection: #1e4d54;
|
--list-selection: #1e4d54;
|
||||||
--dock-selection: #1d4850;
|
--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;
|
--sidebar-width: 158px;
|
||||||
--header-height: 68px;
|
--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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user