Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1291ae086d | ||
|
|
03768c398d | ||
|
|
bfcec9135a | ||
|
|
316dbfc1ff | ||
|
|
ed26391651 | ||
|
|
b47d28375b | ||
|
|
c158befa83 | ||
|
|
22a3da9f6f | ||
|
|
5763c6313b | ||
|
|
7097b1ede7 |
@@ -100,6 +100,40 @@ version = "1.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aurora-curator"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"aurora-curator-core",
|
||||||
|
"dirs",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-build",
|
||||||
|
"tauri-plugin-dialog",
|
||||||
|
"tokio",
|
||||||
|
"walkdir",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aurora-curator-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"matroska",
|
||||||
|
"md-5",
|
||||||
|
"mockito",
|
||||||
|
"mp4",
|
||||||
|
"regex",
|
||||||
|
"reqwest 0.12.28",
|
||||||
|
"rhai",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha1",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -148,6 +182,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitstream-io"
|
||||||
|
version = "4.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f"
|
||||||
|
dependencies = [
|
||||||
|
"no_std_io2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -1904,6 +1947,26 @@ dependencies = [
|
|||||||
"web_atoms",
|
"web_atoms",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matroska"
|
||||||
|
version = "0.30.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d3e606fd165c30a7f5daebca07f9707b2cdca959e0b3dd89538e3e0441b7f52"
|
||||||
|
dependencies = [
|
||||||
|
"bitstream-io",
|
||||||
|
"phf",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "md-5"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.3"
|
version = "2.8.3"
|
||||||
@@ -1981,6 +2044,20 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mp4"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"bytes",
|
||||||
|
"num-rational",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.19.3"
|
version = "0.19.3"
|
||||||
@@ -2041,12 +2118,52 @@ dependencies = [
|
|||||||
"spin",
|
"spin",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "no_std_io2"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-bigint"
|
||||||
|
version = "0.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||||
|
dependencies = [
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-integer"
|
||||||
|
version = "0.1.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-rational"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -2770,34 +2887,6 @@ version = "0.8.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "renamr"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"renamr-core",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"tauri",
|
|
||||||
"tauri-build",
|
|
||||||
"tauri-plugin-dialog",
|
|
||||||
"tokio",
|
|
||||||
"walkdir",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "renamr-core"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"mockito",
|
|
||||||
"regex",
|
|
||||||
"reqwest 0.12.28",
|
|
||||||
"rhai",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"tokio",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.12.28"
|
version = "0.12.28"
|
||||||
@@ -3265,6 +3354,17 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["crates/renamr-core", "src-tauri"]
|
members = ["crates/aurora-curator-core", "src-tauri"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Renamr
|
# Aurora Curator
|
||||||
|
|
||||||
A fast, cross-platform media renamer — a ground-up rewrite of FileBot (via its FB-Mod
|
A fast, cross-platform media renamer — a ground-up rewrite of FileBot (via its FB-Mod
|
||||||
fork), built from scratch as its own project: new stack, new architecture, new branding.
|
fork), built from scratch as its own project: new stack, new architecture, new branding.
|
||||||
@@ -6,10 +6,14 @@ fork), built from scratch as its own project: new stack, new architecture, new b
|
|||||||
**Status: early scaffold, but the core loop works end to end.** The naming-expression
|
**Status: early scaffold, but the core loop works end to end.** The naming-expression
|
||||||
engine is ported and tested, the faithfully-recreated two-pane UI is wired to real
|
engine is ported and tested, the faithfully-recreated two-pane UI is wired to real
|
||||||
native file/folder loading and drag-and-drop, local filename-based season/episode *and
|
native file/folder loading and drag-and-drop, local filename-based season/episode *and
|
||||||
movie title/year* detection ("Match") is working, real online metadata lookups against
|
movie title/year* detection ("Match") is working, real online metadata lookups
|
||||||
TMDB ("Fetch Data") are working for both TV episodes and movies — including fuzzy
|
("Fetch Data") are working for both TV episodes and movies — including fuzzy title
|
||||||
title disambiguation, so a search that returns several similarly-named results doesn't
|
disambiguation, so a search that returns several similarly-named results doesn't just
|
||||||
just take TMDB's top-ranked hit — and the actual on-disk operation is wired up (with a
|
take the source's top-ranked hit. TV episodes can resolve against TMDB, TVmaze, or
|
||||||
|
OMDb, and movies against TMDB or OMDb — each picked independently in Fetch Data
|
||||||
|
Settings, TMDB stays the default for both, TVmaze needs no API token at all (it has no
|
||||||
|
movie catalog, so it's TV-only), and TMDB/OMDb each need their own free saved
|
||||||
|
credential before a batch resolved against them will succeed. The actual on-disk operation is wired up (with a
|
||||||
confirmation step showing the full old-path/new-path plan before anything touches disk,
|
confirmation step showing the full old-path/new-path plan before anything touches disk,
|
||||||
a per-row ✕ to drop an individual file from that plan at the last step, a Move/Copy/
|
a per-row ✕ to drop an individual file from that plan at the last step, a Move/Copy/
|
||||||
Symlink selector for what to actually do at each target, and — independently — a Skip/
|
Symlink selector for what to actually do at each target, and — independently — a Skip/
|
||||||
@@ -26,12 +30,41 @@ TV episodes and movies
|
|||||||
text straight out of the filename — resolution, source (BluRay/HDTV/WEB-DL/…),
|
text straight out of the filename — resolution, source (BluRay/HDTV/WEB-DL/…),
|
||||||
video/audio codec, release group — so naming schemes can reference
|
video/audio codec, release group — so naming schemes can reference
|
||||||
`{resolution}`/`{source}`/`{video codec}`/`{audio codec}`/`{release group}` (or
|
`{resolution}`/`{source}`/`{video codec}`/`{audio codec}`/`{release group}` (or
|
||||||
FileBot's own `{vf}`/`{source}`/`{vc}`/`{ac}`/`{group}`) without a media prober; the
|
FileBot's own `{vf}`/`{source}`/`{vc}`/`{ac}`/`{group}`), and now also probes each
|
||||||
rest of FileBot's full media-metadata bindings (real probed video/audio stream info)
|
file's own container header for its real resolution, frame rate, duration, and
|
||||||
are not yet ported. The Align Up/Down buttons reorder the (shared) file list — the two
|
codecs — no ffmpeg/MediaInfo dependency required — filling in `{resolution}`/
|
||||||
|
`{video codec}`/`{audio codec}` whenever the filename itself didn't already carry
|
||||||
|
that tag, plus `{width}`/`{height}`/`{fps}`/`{duration}`/`{file size}`, which have no
|
||||||
|
filename-tag equivalent at all; see "Real media-file probing" below. The Align
|
||||||
|
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
|
||||||
Settings/History). See "Porting roadmap" below.
|
Settings/History). The app can also be driven entirely from the command line —
|
||||||
|
`aurora-curator -rename --format "<naming scheme>" [options] <file-or-folder>...` — for
|
||||||
|
scripting batch renames without opening the window at all; see "Command-line usage"
|
||||||
|
below. A new Subtitles panel searches and downloads subtitle files from
|
||||||
|
[OpenSubtitles](https://www.opensubtitles.com/) — load a video, its title/season/episode
|
||||||
|
(or movie title/year) auto-fills from the same local matching "Match" uses, pick a
|
||||||
|
result, and the subtitle is saved right next to the video ready for any player to pick
|
||||||
|
up automatically; see "Subtitles" below. A new Episodes panel searches a series by
|
||||||
|
name and browses a full season's episode list — episode number, title, air date — using
|
||||||
|
whichever TV source (TMDB/TVmaze/OMDb) is already configured in Fetch Data Settings, no
|
||||||
|
separate credentials needed; see "Episodes" below. A new SFV panel computes and verifies
|
||||||
|
CRC32/MD5/SHA1 checksums for a loaded set of files, and reads and writes the classic
|
||||||
|
`.sfv`/`.md5`/`.sha1` sidecar-file formats — entirely local, no network access or
|
||||||
|
credentials of any kind; see "SFV" below. A new Filter panel loads a batch of files and
|
||||||
|
narrows it down by extension, TV/Movie/Unmatched kind, filename text, and (for TV) season
|
||||||
|
— all computed from the same local, filename-only matching "Match" uses, with a Copy File
|
||||||
|
Paths export for whatever's left; see "Filter" below. A new List panel renders a
|
||||||
|
user-editable naming-scheme-style expression per loaded file (the same `{ }` syntax and
|
||||||
|
`render_expression` engine the Rename panel's live preview uses, fed by the same local
|
||||||
|
matching "Match"/Filter use), with a live "N of M lines rendered" count, a Copy List
|
||||||
|
clipboard export, and an Export to File… button that writes the whole list to a `.txt`
|
||||||
|
file via a native Save dialog — this closes out the full original panel set (Rename/
|
||||||
|
Episodes/Subtitles/SFV/Filter/List); see "List" below. Real media-file probing (real
|
||||||
|
resolution, frame rate, duration, and codecs read straight from a video file's own
|
||||||
|
container header, not guessed from its filename) is wired into the Rename panel's
|
||||||
|
"Match" step — see "Real media-file probing" below. See "Porting roadmap" below.
|
||||||
|
|
||||||
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
|
||||||
@@ -48,7 +81,7 @@ The original is a ~20k+ line Java/Swing app built with Ant. The goals were: fast
|
|||||||
startup and UI, faster matching/renaming, a codebase that's actually pleasant to work
|
startup and UI, faster matching/renaming, a codebase that's actually pleasant to work
|
||||||
in, and a genuine rebrand — not a coat of paint.
|
in, and a genuine rebrand — not a coat of paint.
|
||||||
|
|
||||||
Renamr is **Tauri**: a Rust core (`crates/renamr-core`, `src-tauri`) plus a
|
Aurora Curator is **Tauri**: a Rust core (`crates/aurora-curator-core`, `src-tauri`) plus a
|
||||||
TypeScript/React frontend (`src`), compiled to a small native binary per platform
|
TypeScript/React frontend (`src`), compiled to a small native binary per platform
|
||||||
(Windows/macOS/Linux) with a system webview instead of shipping a bundled browser
|
(Windows/macOS/Linux) with a system webview instead of shipping a bundled browser
|
||||||
engine. That's the concrete "faster" win over both the original JVM/Swing app and
|
engine. That's the concrete "faster" win over both the original JVM/Swing app and
|
||||||
@@ -69,7 +102,7 @@ template syntax compatible with FileBot conventions, so existing automation most
|
|||||||
keeps working.
|
keeps working.
|
||||||
|
|
||||||
One deliberate gap: FileBot's `{ }` expressions ran arbitrary **Groovy** code
|
One deliberate gap: FileBot's `{ }` expressions ran arbitrary **Groovy** code
|
||||||
(JVM-only). Renamr's Rust core has no Groovy engine, so expressions are evaluated with
|
(JVM-only). Aurora Curator's Rust core has no Groovy engine, so expressions are evaluated with
|
||||||
[Rhai](https://rhai.rs/) instead. Simple bindings (`{n}`, `{s00e00}`, `{t}`) are
|
[Rhai](https://rhai.rs/) instead. Simple bindings (`{n}`, `{s00e00}`, `{t}`) are
|
||||||
identical. More complex chained-method expressions will need small syntax tweaks
|
identical. More complex chained-method expressions will need small syntax tweaks
|
||||||
(e.g. Groovy's `.toUpperCase()` → Rhai's `.to_upper()`) — Rhai already ships most of the
|
(e.g. Groovy's `.toUpperCase()` → Rhai's `.to_upper()`) — Rhai already ships most of the
|
||||||
@@ -79,7 +112,7 @@ translate directly.
|
|||||||
### Naming scheme syntax
|
### Naming scheme syntax
|
||||||
|
|
||||||
Every `{ }` token in an expression is resolved in this order
|
Every `{ }` token in an expression is resolved in this order
|
||||||
(`crates/renamr-core/src/format/expression.rs::compile_scriptlet`):
|
(`crates/aurora-curator-core/src/format/expression.rs::compile_scriptlet`):
|
||||||
|
|
||||||
1. **A single bare identifier** (`{n}`, `{season}`) is a direct binding lookup — the
|
1. **A single bare identifier** (`{n}`, `{season}`) is a direct binding lookup — the
|
||||||
fast path, and how FileBot's own short field names keep working unmodified.
|
fast path, and how FileBot's own short field names keep working unmodified.
|
||||||
@@ -109,12 +142,12 @@ a subfolder" rather than rejecting it.
|
|||||||
A naming scheme that includes `/` (e.g. `{show name}/Season {season number}/{episode
|
A naming scheme that includes `/` (e.g. `{show name}/Season {season number}/{episode
|
||||||
number} - {episode name}`) moves the file into that subfolder as part of renaming,
|
number} - {episode name}`) moves the file into that subfolder as part of renaming,
|
||||||
creating any missing intermediate folders automatically
|
creating any missing intermediate folders automatically
|
||||||
(`crates/renamr-core/src/rename/mod.rs::rename_one` → `fs::create_dir_all`). This is
|
(`crates/aurora-curator-core/src/rename/mod.rs::rename_one` → `fs::create_dir_all`). This is
|
||||||
still always scoped to the file's own directory tree — the target is resolved relative
|
still always scoped to the file's own directory tree — the target is resolved relative
|
||||||
to the source file's parent directory, never as an absolute path, and every path
|
to the source file's parent directory, never as an absolute path, and every path
|
||||||
component is validated (`validate_relative_target`) to reject empty segments (a
|
component is validated (`validate_relative_target`) to reject empty segments (a
|
||||||
leading, trailing, or doubled `/` — which also catches an absolute-looking
|
leading, trailing, or doubled `/` — which also catches an absolute-looking
|
||||||
`/etc/passwd`-style path), `.`/`..` segments, and backslashes (Renamr always uses `/`
|
`/etc/passwd`-style path), `.`/`..` segments, and backslashes (Aurora Curator always uses `/`
|
||||||
for the naming scheme's folder separator regardless of OS). If the resolved target
|
for the naming scheme's folder separator regardless of OS). If the resolved target
|
||||||
already exists and isn't the same underlying file, what happens next is up to the
|
already exists and isn't the same underlying file, what happens next is up to the
|
||||||
chosen conflict policy — Skip (default), Auto-number, or Override; see item 6 of the
|
chosen conflict policy — Skip (default), Auto-number, or Override; see item 6 of the
|
||||||
@@ -123,44 +156,148 @@ porting roadmap below for the full behavior of each.
|
|||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
crates/renamr-core/ Pure Rust core: naming-expression engine (ported), local
|
crates/aurora-curator-core/ Pure Rust core: naming-expression engine (ported), local
|
||||||
filename-based season/episode *and movie title/year* matching
|
filename-based season/episode *and movie title/year* matching
|
||||||
(ported, `matching/`), plus filename-based release-tag parsing in
|
(ported, `matching/`), plus filename-based release-tag parsing in
|
||||||
the same module (`guess_release_info` — resolution, source, video/
|
the same module (`guess_release_info` — resolution, source, video/
|
||||||
audio codec, release group), text-similarity scoring for
|
audio codec, release group), text-similarity scoring for
|
||||||
disambiguating search results (`similarity/`, partial
|
disambiguating search results (`similarity/`, partial
|
||||||
`EpisodeMetrics` port), a TMDB metadata client (`web/tmdb.rs` — TV
|
`EpisodeMetrics` port), three web metadata clients — `web/tmdb.rs`
|
||||||
search + episode lookup
|
(TV search + episode lookup and movie search + lookup),
|
||||||
and movie search + lookup, both disambiguated via `similarity/`,
|
`web/tvmaze.rs` (TV search + episode lookup only — TVmaze has no
|
||||||
tested against a mocked HTTP server via `mockito`), the on-disk
|
movie catalog, and no API key/auth at all), and `web/omdb.rs` (TV
|
||||||
|
*and* movie search + lookup, single flat root-URL REST API, plain
|
||||||
|
`apikey` query-param auth rather than TMDB's Bearer token) — all
|
||||||
|
three disambiguated via
|
||||||
|
`similarity/` and tested against a mocked HTTP server via
|
||||||
|
`mockito`, plus a fourth web client that isn't a metadata
|
||||||
|
lookup at all — `web/opensubtitles.rs` (search + login +
|
||||||
|
download against OpenSubtitles' REST API; search only needs an
|
||||||
|
app-level API key, download additionally needs the end user's own
|
||||||
|
account login, since downloads are quota-limited per account —
|
||||||
|
see "Subtitles" below). The Episodes panel adds no new core-crate
|
||||||
|
code of its own — it reuses `metadata::AnyTvClient::fetch_season`
|
||||||
|
(the same season-resolving call `AnyTvClient` already made available
|
||||||
|
to "Fetch Data" and the CLI) from the Tauri side, so `web/tmdb.rs`/
|
||||||
|
`tvmaze.rs`/`omdb.rs`'s existing season-episode-list methods are
|
||||||
|
exactly what it browses. `sfv/` computes CRC32/MD5/SHA1 checksums
|
||||||
|
(streamed in 64KB chunks so large video files aren't loaded whole
|
||||||
|
into memory) and parses/builds both sidecar-file conventions the
|
||||||
|
SFV panel supports — the traditional `.sfv` layout (CRC32 only,
|
||||||
|
`<name> <checksum>` per line) and the GNU coreutils
|
||||||
|
`md5sum`/`sha1sum` layout (`<checksum> <name>`) used for `.md5`/
|
||||||
|
`.sha1` — dispatched on a shared `ChecksumKind` enum, plus
|
||||||
|
entry-verification against real files on disk (`Ok`/`Mismatch`/
|
||||||
|
`Missing` per entry). No network access, no credentials — entirely
|
||||||
|
local, unlike every other panel added this session. The on-disk
|
||||||
rename engine (`rename/` — Move/Copy/Symlink actions,
|
rename engine (`rename/` — Move/Copy/Symlink actions,
|
||||||
Skip/Auto-number/Override conflict policies, can also organize
|
Skip/Auto-number/Override conflict policies, can also organize
|
||||||
into (auto-created) subfolders when
|
into (auto-created) subfolders when
|
||||||
the naming scheme includes `/`) — and a rename history/undo log
|
the naming scheme includes `/`), a rename history/undo log
|
||||||
(`history/`, a partial `HistoryDialog` port, undo-aware of which
|
(`history/`, a partial `HistoryDialog` port, undo-aware of which
|
||||||
action produced each batch). Not yet ported: full media metadata
|
action produced each batch), pure `-rename` command-line
|
||||||
bindings, other web datasource clients (TVDB/AniDB/etc).
|
argument parsing/file expansion (`cli/` — no Tauri or network
|
||||||
No Tauri dependency, so it builds and unit-tests standalone.
|
dependency at all, see "Command-line usage" below), and real
|
||||||
src-tauri/ Tauri app shell: exposes renamr-core as commands to the frontend
|
media-file container probing (`probe/` — real resolution, frame
|
||||||
|
rate, duration, and codecs read straight from a video file's own
|
||||||
|
header via two pure-Rust container parsers, the `mp4` crate for
|
||||||
|
ISO-BMFF containers and the `matroska` crate for EBML/Matroska
|
||||||
|
containers, no ffmpeg/MediaInfo system dependency; see "Real
|
||||||
|
media-file probing" below). Not yet ported: other web datasource
|
||||||
|
clients (TheTVDB/AniDB/etc), and everything MediaBindingBean
|
||||||
|
derives that neither a filename tag nor `probe/`'s two supported
|
||||||
|
container families can provide (other containers like .avi/.wmv,
|
||||||
|
audio-only metadata like bitrate/sample rate). No Tauri
|
||||||
|
dependency, so it builds and unit-tests standalone.
|
||||||
|
src-tauri/ Tauri app shell: exposes aurora-curator-core as commands to the frontend
|
||||||
(naming-scheme rendering, filename matching for both TV and
|
(naming-scheme rendering, filename matching for both TV and
|
||||||
movies, TMDB metadata fetch — `metadata.rs` for TV episodes,
|
movies, metadata fetch — `metadata.rs` for TV episodes (against
|
||||||
plus a movie-fetch command alongside it — executing a rename
|
whichever of TMDB/TVmaze/OMDb is the stored TV source) plus a
|
||||||
|
movie-fetch command alongside it (against whichever of TMDB/OMDb
|
||||||
|
is the stored movie source — independent of the TV source)
|
||||||
|
— executing a rename
|
||||||
plan, native file/folder dialogs, drag-and-drop expansion), plus
|
plan, native file/folder dialogs, drag-and-drop expansion), plus
|
||||||
`settings.rs` (TMDB token), `history.rs` (rename history/undo
|
`settings.rs` (TMDB token, OMDb API key, OpenSubtitles API key +
|
||||||
log), and `presets.rs` (up to 9 saved naming schemes) — all three
|
account login, and which TV *and* movie
|
||||||
stored as JSON in the OS app-config directory — app
|
metadata source Fetch Data uses — independent settings; the
|
||||||
|
OpenSubtitles password is the one credential this module never
|
||||||
|
returns to the frontend, only whether one is saved),
|
||||||
|
`subtitles.rs` (search/download commands wrapping
|
||||||
|
`aurora-curator-core::web::opensubtitles`, saving a downloaded
|
||||||
|
subtitle right next to its video file), `episodes.rs` (the
|
||||||
|
`browse_episodes` command backing the Episodes panel — series
|
||||||
|
search + one season's episode list, via a `build_tv_client` helper
|
||||||
|
now shared with `metadata.rs`'s `fetch_metadata`, so both read the
|
||||||
|
same stored TV-source setting and credential), `sfv.rs` (three
|
||||||
|
commands backing the SFV panel — `compute_checksums` over a batch
|
||||||
|
of loaded files with per-file error isolation, `save_checksum_file`
|
||||||
|
via a native "Save File" dialog, and `verify_checksum_file`, which
|
||||||
|
reads and parses a picked `.sfv`/`.md5`/`.sha1` file and resolves
|
||||||
|
each entry's bare filename relative to that file's own parent
|
||||||
|
directory — all wrapping `aurora-curator-core::sfv`, which does the
|
||||||
|
actual algorithm/format work),
|
||||||
|
`history.rs` (rename history/undo
|
||||||
|
log), `presets.rs` (up to 9 saved naming schemes) — all three
|
||||||
|
stored as JSON in the OS app-config directory — `list.rs` (a
|
||||||
|
single `export_text_list` command backing the List panel's Export
|
||||||
|
to File… button: opens a native "Save File" dialog and writes
|
||||||
|
whatever already-rendered, newline-joined text the frontend hands
|
||||||
|
it to a `.txt` file — the only new Tauri surface List needed, since
|
||||||
|
matching each file and rendering its line both reuse existing
|
||||||
|
commands, see below), `probe.rs` (a single `probe_media_files`
|
||||||
|
command backing real media probing in the Rename panel's Match
|
||||||
|
step: batch-probes every loaded file's real container header off
|
||||||
|
the main thread, wrapping `aurora-curator-core::probe` — see "Real
|
||||||
|
media-file probing" below) — `cli.rs` (the
|
||||||
|
`-rename` command-line mode's orchestration: builds and executes a
|
||||||
|
rename plan against `aurora-curator-core::cli`'s parsed arguments,
|
||||||
|
with no Tauri `App`/window ever created for it — `main.rs` branches
|
||||||
|
to it before the GUI is touched at all), and app
|
||||||
metadata/icons/window config (tauri.conf.json).
|
metadata/icons/window config (tauri.conf.json).
|
||||||
src/ React + TypeScript UI (Vite). Faithful recreation of the original
|
src/ React + TypeScript UI (Vite). Faithful recreation of the original
|
||||||
two-pane rename screen (Aurora theme) wired to real file loading,
|
two-pane rename screen (Aurora theme) wired to real file loading,
|
||||||
drag-and-drop, live naming-scheme preview, local TV/movie
|
drag-and-drop, live naming-scheme preview, local TV/movie
|
||||||
matching, TMDB fetch for both (behind a Settings modal for the
|
matching, real media-file probing (`probe_media_files`, batched
|
||||||
API token), a confirm-before-touching-disk modal for the actual
|
alongside the local matching calls in `RenamePanel.tsx`'s
|
||||||
|
`runMatching`), metadata fetch for both (behind a Fetch Data Settings
|
||||||
|
modal holding independent TV-source (TMDB/TVmaze/OMDb) and
|
||||||
|
movie-source (TMDB/OMDb) pickers plus the TMDB token and OMDb key
|
||||||
|
inputs), a
|
||||||
|
confirm-before-touching-disk modal for the actual
|
||||||
rename, a History panel that can undo a past rename, Align Up/Down
|
rename, a History panel that can undo a past rename, Align Up/Down
|
||||||
list reordering, and a Presets panel (plus `1`-`9` hotkeys) for
|
list reordering, and a Presets panel (plus `1`-`9` hotkeys) for
|
||||||
saved naming schemes.
|
saved naming schemes. `SubtitlesPanel.tsx` is a separate top-level
|
||||||
scripts/gen_icons.py Regenerates src-tauri/icons/ (placeholder branding — swap the
|
panel (its own Subtitle Settings modal, its own credential state)
|
||||||
colors/glyph in the script, or replace the icons directly, to
|
reached from the sidebar's "Subtitles" entry — the first sidebar
|
||||||
change the look).
|
entry besides Rename to be a real screen rather than a stub.
|
||||||
|
`EpisodesPanel.tsx` is the second: a series-search + season-browse
|
||||||
|
screen with no settings modal of its own at all — it deliberately
|
||||||
|
has no separate credentials UI, reading whatever TV source/token
|
||||||
|
Fetch Data Settings (in the Rename panel) already has saved.
|
||||||
|
`SfvPanel.tsx` is the third: load a set of files, compute
|
||||||
|
checksums in any of CRC32/MD5/SHA1, save them as a sidecar
|
||||||
|
checksum file via a native Save dialog, or pick an existing
|
||||||
|
checksum file and verify the files it lists against what's
|
||||||
|
actually on disk (OK/mismatch/missing per entry) — no settings
|
||||||
|
modal at all, since the feature needs no credentials or network
|
||||||
|
access. `FilterPanel.tsx` is the fourth: load a set of files and
|
||||||
|
narrow them down with toggleable extension/kind chips, a
|
||||||
|
filename-contains text field, and a season number field (TV
|
||||||
|
only) — adds no new Rust/Tauri commands at all, reusing
|
||||||
|
`pick_files`/`guess_episode`/`guess_movie`/`guess_release_info`
|
||||||
|
exactly as the Rename panel's own "Match" does, just applied to
|
||||||
|
a second, independently-loaded file list and rendered as
|
||||||
|
criteria instead of a rename preview. `ListPanel.tsx` is the
|
||||||
|
fifth and last: load a set of files, edit a naming-scheme-style
|
||||||
|
expression (rendered per file via the same `render_expression`
|
||||||
|
command the Rename panel's live preview calls, fed by the same
|
||||||
|
local matching Filter uses), see a live "N of M lines rendered"
|
||||||
|
list, Copy List to the clipboard, or Export to File… via the one
|
||||||
|
new `export_text_list` command above — closing out the full
|
||||||
|
original panel set.
|
||||||
|
scripts/gen_icons.py Regenerates src-tauri/icons/ from the real logo mark
|
||||||
|
(`scripts/logo/icon-source.png`, cropped from the supplied
|
||||||
|
lockup) — replace that source image to change the look.
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
@@ -172,7 +309,7 @@ toolchain; macOS/Windows need Xcode / the VS Build Tools + WebView2 respectively
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Rust core (no UI deps needed):
|
# Rust core (no UI deps needed):
|
||||||
cargo test -p renamr-core
|
cargo test -p aurora-curator-core
|
||||||
|
|
||||||
# Full app in dev mode (opens a native window):
|
# Full app in dev mode (opens a native window):
|
||||||
npm install
|
npm install
|
||||||
@@ -182,25 +319,257 @@ npm run tauri dev
|
|||||||
npm run tauri build
|
npm run tauri build
|
||||||
```
|
```
|
||||||
|
|
||||||
Verified in the environment this was built in: `cargo test -p renamr-core` (all
|
Verified in the environment this was built in: `cargo test --workspace` (all 160
|
||||||
passing, including the TMDB client tests — those run against a local mock HTTP server
|
passing, including the TMDB, TVmaze, OMDb, and OpenSubtitles client tests — those run
|
||||||
via `mockito`, no live network or API key needed to build/test), `cargo check -p
|
against a local mock HTTP server via `mockito`, no live network or API key needed to
|
||||||
renamr` (compiles clean against real Tauri + GTK/WebKit system libraries), and `npm run
|
build/test), `cargo
|
||||||
build` (TypeScript type-checks, Vite bundles). The actual GUI window has **not** been
|
check -p aurora-curator` (compiles clean against real Tauri + GTK/WebKit system
|
||||||
visually verified anywhere yet — that sandbox has no display. Run `npm run tauri dev`
|
libraries), and `npm run build` (TypeScript type-checks, Vite bundles). The actual GUI
|
||||||
on your machine to see and click through it for the first time.
|
window has **not** been visually verified anywhere yet — that sandbox has no display.
|
||||||
|
Run `npm run tauri dev` on your machine to see and click through it for the first time.
|
||||||
|
|
||||||
### Setting up TMDB (for "Fetch Data")
|
### Setting up metadata sources (for "Fetch Data")
|
||||||
|
|
||||||
Everything else in the app works fully offline; "Fetch Data" is the one feature that
|
Everything else in the app works fully offline; "Fetch Data" is the one feature that
|
||||||
needs a (free) [TMDB](https://www.themoviedb.org/) account:
|
talks to the network, and it's the only one that needs any setup at all — and even that
|
||||||
|
setup is optional for TV.
|
||||||
|
|
||||||
1. Create a TMDB account, then go to Settings → API in your account and generate an
|
TV episodes and movies each have their own independent source picker in the app (click
|
||||||
**API Read Access Token** (not the older numeric "API Key" — Renamr uses the Bearer
|
the key icon, "Fetch Data Settings", next to Fetch Data) — a batch can freely mix TV and
|
||||||
token method TMDB documents as the default).
|
movie files resolved against two different sources at once:
|
||||||
2. In the app, click the key icon ("TMDB Settings") next to Fetch Data and paste the
|
|
||||||
token in. It's saved locally in the OS app-config directory and only ever sent to
|
- **[TVmaze](https://www.tvmaze.com/)** — TV only (it has no movie catalog). Free, no
|
||||||
`api.themoviedb.org`.
|
account or API token needed at all. Just pick it and go.
|
||||||
|
- **[TMDB](https://www.themoviedb.org/)** — the default for both TV and movies. Needs a
|
||||||
|
free account:
|
||||||
|
1. Create a TMDB account, then go to Settings → API in your account and generate an
|
||||||
|
**API Read Access Token** (not the older numeric "API Key" — Aurora Curator uses the
|
||||||
|
Bearer token method TMDB documents as the default).
|
||||||
|
2. In the app, open "Fetch Data Settings" and paste the token in. It's saved locally
|
||||||
|
in the OS app-config directory and only ever sent to `api.themoviedb.org`.
|
||||||
|
- **[OMDb](https://www.omdbapi.com/)** — TV and movies, an alternative to TMDB for
|
||||||
|
either or both. Needs a free API key:
|
||||||
|
1. Request a free key at <https://www.omdbapi.com/apikey.aspx> (OMDb emails it to you).
|
||||||
|
2. In the app, open "Fetch Data Settings" and paste the key into the OMDb field. It's
|
||||||
|
saved locally in the OS app-config directory and only ever sent to
|
||||||
|
`www.omdbapi.com`.
|
||||||
|
|
||||||
|
A batch of TV-only files can run entirely on TVmaze with no credential configured at
|
||||||
|
all. Otherwise, whichever of TMDB/OMDb is actually selected — for TV, for movies, or
|
||||||
|
both — needs its own credential saved first; picking TMDB for TV and OMDb for movies (or
|
||||||
|
any other mix) only needs the two credentials that combination actually uses. Note OMDb
|
||||||
|
returns a normal-looking (HTTP 200) "no results" response for both a genuine no-match and
|
||||||
|
an invalid API key, so a bad key currently surfaces as "not found" rather than a clearer
|
||||||
|
credential error — a known OMDb API limitation, not something Aurora Curator can
|
||||||
|
distinguish from its response alone.
|
||||||
|
|
||||||
|
## Command-line usage
|
||||||
|
|
||||||
|
The same binary that opens the GUI can also run a batch rename straight from a script or
|
||||||
|
terminal — a port of FileBot's `net.filebot.cli` flag surface (`-rename`, `--format`,
|
||||||
|
`--db`, `--action`, `--conflict`), with a couple of deliberate differences noted below.
|
||||||
|
Passing `-rename` (or `-h`/`--help`) on the command line skips the window entirely:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
aurora-curator -rename --format "{show name} - {season number}x{episode number} - {episode name}" \
|
||||||
|
--action move --conflict skip ~/Downloads/some-show/
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--format "<naming scheme>"` (required) — the same expression syntax as the GUI's
|
||||||
|
Naming Scheme field: FileBot short names (`{n}`, `{s00e00}`, `{t}`), human-readable
|
||||||
|
aliases (`{show name}`, `{episode name}`, `{movie name}`, `{resolution}`, `{video
|
||||||
|
codec}`, `{release group}`, …), or real Rhai script. `{name}`/`{ext}` fall back to the
|
||||||
|
file's own current filename/extension when nothing else matched it.
|
||||||
|
- `--db <tmdb|tvmaze|omdb>` (optional) — resolves TV episodes against that online source
|
||||||
|
before rendering names, the command-line equivalent of clicking "Fetch Data" after
|
||||||
|
Match. Movies are also looked up online whenever this is `tmdb` or `omdb` — TVmaze has
|
||||||
|
no movie catalog, so `--db tvmaze` only affects the TV half of a batch — and with no
|
||||||
|
`--db` at all every file uses local filename matching only, same as Match without Fetch
|
||||||
|
Data in the GUI. `--db tmdb`/`--db omdb` each need the matching token/key that's already
|
||||||
|
been saved through the app's own Fetch Data Settings at least once — **the CLI never
|
||||||
|
accepts an API token or key as an argument**, so there's nothing secret to leak into
|
||||||
|
your shell history or a script.
|
||||||
|
- `--action <move|copy|symlink|test>` (default `move`) — what to do at each computed
|
||||||
|
target; `test` prints the full plan and touches nothing on disk, useful for checking a
|
||||||
|
naming scheme before committing to it.
|
||||||
|
- `--conflict <skip|auto-number|override>` (default `skip`, `autonumber`/`auto` also
|
||||||
|
accepted) — what happens when the computed target already exists.
|
||||||
|
- `-non-strict` — accepted for compatibility with existing FileBot scripts, but is a
|
||||||
|
genuine no-op here: the local matcher has no confidence score to loosen, and online
|
||||||
|
disambiguation (`similarity::best_title_match`) always runs regardless of this flag.
|
||||||
|
- Every remaining argument is treated as an input file or folder; folders are expanded
|
||||||
|
recursively (hidden files/dotfolders skipped), matching exactly what the GUI's own
|
||||||
|
"Select Folder…" and drag-and-drop do.
|
||||||
|
|
||||||
|
Deliberately **not** ported: FileBot's `--output <dir>` flag. The rename engine is
|
||||||
|
scoped, as a safety invariant, to never move a file outside its own source directory
|
||||||
|
tree (see "Organizing into subfolders" above) — a naming scheme's own `/` already covers
|
||||||
|
organizing into a subfolder, and relaxing that invariant just for `--output` wasn't
|
||||||
|
judged worth it.
|
||||||
|
|
||||||
|
## Episodes
|
||||||
|
|
||||||
|
The Episodes sidebar panel searches for a TV series by name and browses one season's
|
||||||
|
full episode list — episode number, title, and air date — as a standalone reference,
|
||||||
|
independent of whatever files (if any) are loaded in the Rename panel.
|
||||||
|
|
||||||
|
Type a series name, pick a season (the +/− stepper re-queries automatically once you've
|
||||||
|
searched at least once), and click **Browse**. A season with a real series match but no
|
||||||
|
episodes of its own (e.g. asking for season 99 of a 5-season show) comes back as a clear
|
||||||
|
"no episodes found for season N" message rather than an error; a series name that
|
||||||
|
doesn't match anything at all surfaces the underlying source's own not-found message. A
|
||||||
|
**Copy list** button copies every row as plain text (`Series Name - SxEE - Episode
|
||||||
|
Title`, one per line) to the clipboard — handy for pasting into a naming reference or
|
||||||
|
anywhere outside the app.
|
||||||
|
|
||||||
|
Unlike Subtitles, this panel has **no settings or credentials of its own**: it reuses
|
||||||
|
whichever TV source — TMDB, TVmaze, or OMDb — is already configured in the Rename
|
||||||
|
panel's Fetch Data Settings, and the same season-lookup call ("Fetch Data" and the
|
||||||
|
`-rename --db` CLI both already made) is exactly what it browses. If nothing is
|
||||||
|
configured yet (only relevant for TMDB/OMDb — TVmaze needs no credential), Browse
|
||||||
|
reports the same "open Settings to add one" message Fetch Data itself would.
|
||||||
|
|
||||||
|
## Subtitles
|
||||||
|
|
||||||
|
The Subtitles sidebar panel searches and downloads subtitle files from
|
||||||
|
[OpenSubtitles](https://www.opensubtitles.com/) — the first thing in Aurora Curator that
|
||||||
|
fetches an actual media file (a subtitle) rather than title/air-date text.
|
||||||
|
|
||||||
|
**Load Video File…** picks one video, then reuses the exact same local filename-based
|
||||||
|
matching "Match" uses (`guess_episode`/`guess_movie`) to pre-fill the search form with a
|
||||||
|
show title + season/episode, or a movie title + year — all editable before searching,
|
||||||
|
and usable on their own with no video loaded at all if you'd rather just type a title.
|
||||||
|
Results show the release name, language, an **HD** badge, a **Hash match** badge when
|
||||||
|
OpenSubtitles matched the file by an exact content hash rather than a text search (a
|
||||||
|
much stronger signal than a title match alone), an **HI** badge for hearing-impaired
|
||||||
|
tracks, the uploader, and the download count. Downloading saves the file right next to
|
||||||
|
the video with the language code inserted before the extension (`Show.S01E01.mkv` →
|
||||||
|
`Show.S01E01.en.srt`) — the convention most media players look for automatically, so
|
||||||
|
there's nothing further to configure.
|
||||||
|
|
||||||
|
OpenSubtitles splits credentials in a way no other source in this app does:
|
||||||
|
|
||||||
|
- **Searching** only needs a free **API key** — register an account at
|
||||||
|
[opensubtitles.com](https://www.opensubtitles.com/) and create one under Account → API
|
||||||
|
Consumers, then paste it into Subtitle Settings.
|
||||||
|
- **Downloading** additionally needs your own OpenSubtitles account **username and
|
||||||
|
password**, entered in the same Settings panel — OpenSubtitles logs you in to check a
|
||||||
|
daily download quota tied to your account (shown after every successful download as
|
||||||
|
"N/M downloads left today"; the exact number depends on your account's standing with
|
||||||
|
OpenSubtitles and can change on their end, so Aurora Curator always shows the live
|
||||||
|
figure it gets back rather than a hardcoded promise). Search works with no login at
|
||||||
|
all, so you can browse what's available before deciding whether to save a password.
|
||||||
|
|
||||||
|
Like every other credential in this app, both are stored locally in the OS app-config
|
||||||
|
directory and only ever sent to `api.opensubtitles.com` — with one extra precaution
|
||||||
|
specific to a real account password: Aurora Curator never sends the saved password back
|
||||||
|
to its own interface once it's saved (every other credential's "already saved" check
|
||||||
|
does briefly see the real value; the password doesn't), and the Settings panel only ever
|
||||||
|
shows *whether* a password is on file, never the value itself.
|
||||||
|
|
||||||
|
## SFV
|
||||||
|
|
||||||
|
The SFV sidebar panel computes and verifies file checksums — CRC32, MD5, or SHA1 — and
|
||||||
|
reads and writes the classic sidecar checksum-file formats, recreating original
|
||||||
|
FileBot's SFV panel. Unlike every other panel added this session, it's **entirely
|
||||||
|
local**: no network access, no credentials, nothing to configure.
|
||||||
|
|
||||||
|
**Load Files…** picks one or more files, then choose a checksum type from the segmented
|
||||||
|
control (CRC32 defaults to the traditional `.sfv` file format; MD5/SHA1 use the GNU
|
||||||
|
coreutils `md5sum`/`sha1sum` sidecar convention instead). **Compute** hashes every loaded
|
||||||
|
file (streamed in 64KB chunks, so even large video files don't need to fit in memory) and
|
||||||
|
lists each one's checksum, or an **ERROR** badge for any individual file that couldn't be
|
||||||
|
read — one bad file never aborts the rest of the batch. **Save Checksum File…** opens a
|
||||||
|
native Save dialog (defaulted to the loaded files' own folder name, e.g. `movies.sfv`)
|
||||||
|
and writes every successfully-computed checksum out in the chosen format; only files that
|
||||||
|
computed successfully are included.
|
||||||
|
|
||||||
|
**Verify Checksum File…** goes the other direction: pick an existing `.sfv`/`.md5`/
|
||||||
|
`.sha1` file (no need to have loaded its files first) and every entry it lists is
|
||||||
|
resolved against the real files sitting next to it on disk and reported as **OK**,
|
||||||
|
**MISMATCH** (actual checksum shown alongside the expected one), or **MISSING** (the
|
||||||
|
referenced file isn't there). Checksum comparison is case-insensitive, since some tools
|
||||||
|
write hex digests in uppercase and others in lowercase.
|
||||||
|
|
||||||
|
## Filter
|
||||||
|
|
||||||
|
The Filter sidebar panel loads a batch of files (independent of whatever's loaded in the
|
||||||
|
Rename panel) and narrows it down before you decide what to do with it elsewhere — copy
|
||||||
|
the matching paths out, or just get a sense of what's actually in a folder. Unlike SFV,
|
||||||
|
it adds no new Rust/Tauri code at all: it scans every loaded file with the exact same
|
||||||
|
local, filename-only matchers "Match" uses (`guess_episode`/`guess_movie`/
|
||||||
|
`guess_release_info`) and turns the results into filter criteria instead of a rename
|
||||||
|
preview — no network calls, so scanning even a large batch is effectively instant.
|
||||||
|
|
||||||
|
**Load Files…** loads and scans a batch in one step. Toggleable chips then appear for
|
||||||
|
every **extension** actually present (e.g. MKV/MP4/TXT) and every **kind** Aurora Curator
|
||||||
|
locally recognized (TV/Movies/Unmatched) — click a chip to exclude that group, click it
|
||||||
|
again to bring it back. A **filename contains** field does a live, case-insensitive
|
||||||
|
substring search, and a **season** field (shown only while the TV chip is enabled) narrows
|
||||||
|
to a single season number, automatically excluding anything that isn't a TV episode in
|
||||||
|
that season. The results list updates live with an "N of M files match" count, and
|
||||||
|
**Copy File Paths** writes every currently-matching file's full path, one per line, to the
|
||||||
|
clipboard.
|
||||||
|
|
||||||
|
## List
|
||||||
|
|
||||||
|
The List sidebar panel loads a batch of files (independent of whatever's loaded
|
||||||
|
elsewhere) and renders a naming-scheme-style expression against each one, so the result
|
||||||
|
can be copied or exported as a plain text list — a playlist, a batch-rename preview to
|
||||||
|
check by eye, a checklist for another tool, whatever the expression is written for.
|
||||||
|
It's the last of the six original sidebar panels (Rename/Episodes/Subtitles/SFV/Filter/
|
||||||
|
List) and, like Filter, adds no new local-matching logic — it reuses the exact same
|
||||||
|
`guess_episode`/`guess_movie`/`guess_release_info` calls "Match" and Filter already use.
|
||||||
|
The one genuinely new piece is a single Tauri command, `export_text_list`, which opens a
|
||||||
|
native Save dialog and writes whatever text the frontend hands it to a `.txt` file —
|
||||||
|
everything else (matching, and rendering each file's line) reuses commands that already
|
||||||
|
existed.
|
||||||
|
|
||||||
|
**Load Files…** loads a batch and scans it locally, the same way Filter does. The
|
||||||
|
**Format** field holds the expression rendered against each file — the exact same `{ }`
|
||||||
|
syntax the naming scheme uses, run through the same `render_expression` command the
|
||||||
|
Rename panel's live preview calls. `{name}`/`{ext}` always work (they're the file's own
|
||||||
|
name/extension); `{show name}`/`{season number}`/`{episode number}`, `{movie name}`/
|
||||||
|
`{year}`, and the release-tag fields (`{resolution}`/`{source}`/`{video codec}`/
|
||||||
|
`{audio codec}`/`{release group}`) fill in once the local scan recognizes a file — there's
|
||||||
|
no Fetch Data step here, List stays local-only like Filter. A file whose rendered line
|
||||||
|
comes out empty (e.g. an expression that only references a field this particular file
|
||||||
|
never matched) shows an "(empty)" placeholder rather than blocking the rest of the list,
|
||||||
|
and the results header tracks "N of M lines rendered" live as the expression changes.
|
||||||
|
**Copy List** copies every line, in file order, newline-joined, to the clipboard; **Export
|
||||||
|
to File…** opens a native Save dialog (defaulted to `list.txt`) and writes the same text
|
||||||
|
to disk.
|
||||||
|
|
||||||
|
## Real media-file probing
|
||||||
|
|
||||||
|
Unlike every panel above, this isn't a new sidebar screen — it's a naming-scheme
|
||||||
|
binding source wired into the Rename panel's existing "Match" step, closing (part of)
|
||||||
|
the gap the porting roadmap has called out since the beginning: FileBot's
|
||||||
|
`MediaBindingBean` doesn't just parse tags out of a release name, it also reads a
|
||||||
|
video file's own container header for its real resolution, frame rate, duration, and
|
||||||
|
codecs — a naming scheme can reference those even when the filename itself doesn't
|
||||||
|
carry a tag for them, or came from a mislabeled/renamed-already release. Aurora
|
||||||
|
Curator ships no ffmpeg/MediaInfo dependency for this — two pure-Rust container
|
||||||
|
parsers do the actual reading: the `mp4` crate for ISO-BMFF containers (`.mp4`/`.m4v`/
|
||||||
|
`.mov`) and the `matroska` crate for EBML/Matroska containers (`.mkv`/`.webm`). Any
|
||||||
|
other extension, or a file that fails to parse as its container, just yields no probed
|
||||||
|
values — probing is always a best-effort enrichment layered on top of filename
|
||||||
|
matching, never a hard requirement, so an unsupported or corrupt file can't break
|
||||||
|
Match.
|
||||||
|
|
||||||
|
Clicking **Match** now also probes every loaded file's real container header
|
||||||
|
(`probe_media_files`, one batched Tauri round trip off the main thread) alongside the
|
||||||
|
existing filename-based guessing. The result feeds the same bindings the release-tag
|
||||||
|
parser already populates — `{resolution}`/`{vf}`, `{video codec}`/`{vc}`,
|
||||||
|
`{audio codec}`/`{ac}` — but only when the filename itself didn't already carry that
|
||||||
|
tag; an explicit release-group tag always wins over a probed value, since a release
|
||||||
|
group's own tag is usually deliberate. Five bindings have no filename-tag equivalent
|
||||||
|
at all, so they're only ever available this way: `{width}`, `{height}`, `{fps}`,
|
||||||
|
`{duration}` (`h:mm:ss`, or `m:ss` under an hour), and `{file size}` (human-readable,
|
||||||
|
e.g. `1.4 GB`) — all formatted once on the Rust side so the UI never reimplements that
|
||||||
|
logic. The Match status line reports how many loaded files were actually probed
|
||||||
|
(`"N probed from file"`), and the naming-scheme editor's field-reference list has a
|
||||||
|
new "Real file probing" group listing all five.
|
||||||
|
|
||||||
## Porting roadmap (not yet done)
|
## Porting roadmap (not yet done)
|
||||||
|
|
||||||
@@ -209,49 +578,79 @@ ported from, for anyone picking this up:
|
|||||||
|
|
||||||
1. **Full naming-scheme bindings** — `net.filebot.format.MediaBindingBean` (1354 lines):
|
1. **Full naming-scheme bindings** — `net.filebot.format.MediaBindingBean` (1354 lines):
|
||||||
all the `{n}`, `{s00e00}`, `{airdate}`, `{vf}` (video format), etc. binding
|
all the `{n}`, `{s00e00}`, `{airdate}`, `{vf}` (video format), etc. binding
|
||||||
properties. A filename-tag-based subset is now ported and wired to "Match"
|
properties. A filename-tag-based subset is ported and wired to "Match"
|
||||||
(`guess_release_info` in `crates/renamr-core/src/matching`): `{vf}`/`{resolution}`,
|
(`guess_release_info` in `crates/aurora-curator-core/src/matching`): `{vf}`/`{resolution}`,
|
||||||
`{source}`, `{vc}`/`{video codec}`, `{ac}`/`{audio codec}`, `{group}`/`{release
|
`{source}`, `{vc}`/`{video codec}`, `{ac}`/`{audio codec}`, `{group}`/`{release
|
||||||
group}`. What's still missing is everything MediaBindingBean derives from actually
|
group}`. Real container probing (`crates/aurora-curator-core/src/probe`, see "Real
|
||||||
*probing* the media file (real video/audio stream resolution, duration, bitrate,
|
media-file probing" below) now covers the other half MediaBindingBean derives from
|
||||||
frame rate, file size/hash, etc.) — Renamr ships no ffprobe/MediaInfo dependency, so
|
actually reading the file — real resolution/video codec/audio codec fill in
|
||||||
those bindings only work when a release name happens to carry the info as text.
|
whenever the filename didn't already carry that tag, plus `{width}`/`{height}`/
|
||||||
|
`{fps}`/`{duration}`/`{file size}`, which have no filename-tag equivalent at all —
|
||||||
|
for the two container families it supports (MP4/M4V/MOV and MKV/WebM). Still
|
||||||
|
missing: probing for other containers (.avi, .wmv, ...), audio-only metadata
|
||||||
|
(bitrate, sample rate, channel layout), file hashing (`{crc32}` reuses the SFV
|
||||||
|
panel's own hasher today but isn't wired into naming-scheme bindings yet), and
|
||||||
|
wiring probed bindings into the List/Filter panels (currently Rename-panel-only).
|
||||||
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
|
Only a small subset (`any`, `all_of`, `concat`) is ported so far
|
||||||
(`crates/renamr-core/src/format/functions.rs`); Rhai's built-ins cover some of the
|
(`crates/aurora-curator-core/src/format/functions.rs`); Rhai's built-ins cover some of the
|
||||||
rest already.
|
rest already.
|
||||||
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/renamr-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
|
||||||
which lacks lookahead/lookbehind — wired to the "Match" button), and so is a local
|
which lacks lookahead/lookbehind — wired to the "Match" button), and so is a local
|
||||||
movie title/year heuristic (`guess_movie` in the same module) used as a fallback
|
movie title/year heuristic (`guess_movie` in the same module) used as a fallback
|
||||||
whenever a filename carries no season/episode marker — a release name is either a TV
|
whenever a filename carries no season/episode marker — a release name is either a TV
|
||||||
episode or a movie, never both. Title disambiguation is also done
|
episode or a movie, never both. Title disambiguation is also done
|
||||||
(`crates/renamr-core/src/similarity`, a partial port of the *scoring* idea behind
|
(`crates/aurora-curator-core/src/similarity`, a partial port of the *scoring* idea behind
|
||||||
`EpisodeMetrics.java`'s fuzzy title-matching cascade — Sorensen-Dice similarity over
|
`EpisodeMetrics.java`'s fuzzy title-matching cascade — Sorensen-Dice similarity over
|
||||||
normalized-title character bigrams), shared by both the TV and movie TMDB lookups:
|
normalized-title character bigrams), shared by both the TV and movie TMDB lookups:
|
||||||
"Fetch Data" picks whichever TMDB search result is textually closest to the local
|
"Fetch Data" picks whichever TMDB search result is textually closest to the local
|
||||||
guess rather than just trusting TMDB's relevance/popularity-ranked first hit, and
|
guess rather than just trusting TMDB's relevance/popularity-ranked first hit, and
|
||||||
reports "not found" instead of guessing when nothing in the results is a plausible
|
reports "not found" instead of guessing when nothing in the results is a plausible
|
||||||
match at all. `DateMatcher` (numbering/air-date-based identification, the other half
|
match at all. `DateMatcher` (numbering/air-date-based identification, the other half
|
||||||
of the original `EpisodeMetrics`) is not ported — Renamr doesn't do date-based
|
of the original `EpisodeMetrics`) is not ported — Aurora Curator doesn't do date-based
|
||||||
episode identification.
|
episode identification.
|
||||||
4. **Media detection** — `net.filebot.media.MediaDetection` (1356 lines): ties file-name
|
4. **Media detection** — `net.filebot.media.MediaDetection` (1356 lines): ties file-name
|
||||||
parsing + matching together to auto-identify what a file is.
|
parsing + matching together to auto-identify what a file is.
|
||||||
5. **Web datasource clients** — `net.filebot.web.*` (~40 files): TheMovieDB, TheTVDB,
|
5. **Web datasource clients** — `net.filebot.web.*` (~40 files): TheMovieDB, TheTVDB,
|
||||||
AniDB, OMDb, TVmaze, OpenSubtitles, etc. TMDB is done for both TV and movies
|
AniDB, OMDb, TVmaze, OpenSubtitles, etc. Three are done: TMDB, for both TV and movies
|
||||||
(`crates/renamr-core/src/web/tmdb.rs`, `reqwest` + `serde_json`, Bearer-token auth) —
|
(`crates/aurora-curator-core/src/web/tmdb.rs`, `reqwest` + `serde_json`, Bearer-token auth) —
|
||||||
search-by-name plus per-season episode lookup for TV, search-by-title (optionally
|
search-by-name plus per-season episode lookup for TV, search-by-title (optionally
|
||||||
narrowed by release year via TMDB's own `primary_release_year` filter) for movies —
|
narrowed by release year via TMDB's own `primary_release_year` filter) for movies —
|
||||||
both wired to "Fetch Data", disambiguating multi-result searches via the
|
TVmaze, for TV only (`crates/aurora-curator-core/src/web/tvmaze.rs`, same `reqwest` +
|
||||||
similarity-scoring pass described in item 3 above rather than blindly trusting
|
`serde_json` shape, no auth at all — TVmaze has no movie catalog to search, and its
|
||||||
TMDB's top-ranked hit. All other sources (TheTVDB, AniDB, OMDb, TVmaze,
|
per-show episode endpoint returns every season in one flat list rather than TMDB's
|
||||||
OpenSubtitles) are not yet started.
|
per-season lookup, so the client fetches once and filters locally, skipping any
|
||||||
|
unnumbered/special episodes rather than panicking on them) — and OMDb, for both TV
|
||||||
|
and movies (`crates/aurora-curator-core/src/web/omdb.rs`, same `reqwest` + `serde_json`
|
||||||
|
shape but a single flat root-URL REST API rather than TMDB's per-resource paths, plain
|
||||||
|
`apikey` query-param auth rather than a Bearer token, and human-text release dates
|
||||||
|
(`"16 Jul 2010"`/`"N/A"`) normalized to ISO form locally; a movie search result only
|
||||||
|
carries a bare year, so `lookup_movie` follows up with a by-ID detail request to get
|
||||||
|
the actual release date). All three are wired to "Fetch
|
||||||
|
Data" — independent "TV episode source" (TMDB/TVmaze/OMDb) and "Movie source"
|
||||||
|
(TMDB/OMDb) pickers in Fetch Data Settings choose which source a batch's TV and movie
|
||||||
|
items resolve against respectively (TMDB stays the default for both; a batch can mix
|
||||||
|
sources across its TV and movie halves) — disambiguating multi-result searches via
|
||||||
|
the similarity-scoring pass described in item 3 above rather than blindly trusting
|
||||||
|
any source's top-ranked hit. The same per-season episode-list methods these three
|
||||||
|
clients already exposed for Fetch Data are also what the new Episodes panel browses
|
||||||
|
directly (see "Episodes" above) — no new client code, just a new UI on top of what
|
||||||
|
was already there. **OpenSubtitles is also done, but as a subtitle source
|
||||||
|
rather than a fourth title-metadata source** — `crates/aurora-curator-core/src/web/opensubtitles.rs`,
|
||||||
|
wired to the new Subtitles panel (see "Subtitles" above) rather than to Fetch Data's
|
||||||
|
TV/movie pickers, since it fetches subtitle files, not episode/movie titles. All
|
||||||
|
other sources (TheTVDB, AniDB) are not yet started — **TheTVDB was deliberately
|
||||||
|
skipped**: as of this writing its v4 API requires either a paid commercial license or
|
||||||
|
the "user-supported" model, where every end user needs their own paid TheTVDB
|
||||||
|
subscription (~$12/year) just to get the PIN its login flow requires, unlike the
|
||||||
|
three free-to-use sources above — worth revisiting if that changes.
|
||||||
6. **Rename engine + CLI** — `net.filebot.cli.CmdlineOperations` (1235 lines) and
|
6. **Rename engine + CLI** — `net.filebot.cli.CmdlineOperations` (1235 lines) and
|
||||||
`ArgumentBean` (415 lines, the full flag surface: `-rename`, `--db`, `--format`,
|
`ArgumentBean` (415 lines, the full flag surface: `-rename`, `--db`, `--format`,
|
||||||
`--action`, `--conflict`, `-get-subtitles`, `-script`, etc.). The rename operation is
|
`--action`, `--conflict`, `-get-subtitles`, `-script`, etc.). The rename operation is
|
||||||
done (`crates/renamr-core/src/rename`, wired to the UI's Rename button behind a
|
done (`crates/aurora-curator-core/src/rename`, wired to the UI's Rename button behind a
|
||||||
confirmation step) including the `move`-into-subfolder `--action` variant — a naming
|
confirmation step) including the `move`-into-subfolder `--action` variant — a naming
|
||||||
scheme like `{n}/{s00e00}` organizes files into subfolders, creating any missing
|
scheme like `{n}/{s00e00}` organizes files into subfolders, creating any missing
|
||||||
folders automatically, always scoped to the file's own directory tree. The
|
folders automatically, always scoped to the file's own directory tree. The
|
||||||
@@ -265,17 +664,35 @@ ported from, for anyone picking this up:
|
|||||||
first free slot), or Override (replaces the conflicting file outright — the one
|
first free slot), or Override (replaces the conflicting file outright — the one
|
||||||
irreversible option, called out with red styling in the UI; undoing that batch later
|
irreversible option, called out with red styling in the UI; undoing that batch later
|
||||||
restores the *source*, never what got overwritten, since undo always uses its own
|
restores the *source*, never what got overwritten, since undo always uses its own
|
||||||
fixed skip-on-conflict policy no matter which policy the original rename used). Not
|
fixed skip-on-conflict policy no matter which policy the original rename used). The
|
||||||
yet done: the CLI flag surface itself.
|
CLI flag surface is done too — `-rename`/`--format`/`--db`/`--action`/`--conflict`/
|
||||||
|
`-non-strict`/`-h` (`crates/aurora-curator-core/src/cli` for pure argument parsing and
|
||||||
|
directory expansion, `src-tauri/src/cli.rs` for the orchestration — local matching,
|
||||||
|
optional TMDB/TVmaze/OMDb lookup, and the actual rename, all without ever constructing a
|
||||||
|
Tauri `App`); see "Command-line usage" above. `--output <dir>` and `-script`/the rest
|
||||||
|
of FileBot's non-rename CLI modes are not ported. `-get-subtitles` has no CLI analog
|
||||||
|
yet — subtitle fetching (see "Subtitles" above) is GUI-only so far, reached from its
|
||||||
|
own sidebar panel rather than a command-line flag.
|
||||||
7. **The rest of the UI** — the Rename panel (file loading, drag-drop, live preview,
|
7. **The rest of the UI** — the Rename panel (file loading, drag-drop, live preview,
|
||||||
local TV/movie matching, confirmed rename with per-item exclude, rename history/undo,
|
local TV/movie matching, confirmed rename with per-item exclude, rename history/undo,
|
||||||
Align Up/Down reordering, naming-scheme Presets with `1`-`9` hotkeys) is built; the
|
Align Up/Down reordering, naming-scheme Presets with `1`-`9` hotkeys) is built, and so
|
||||||
other five sidebar panels (Episodes/Subtitles/SFV/Filter/List) are still stub
|
are the Subtitles panel (search + download via OpenSubtitles, see "Subtitles" above),
|
||||||
screens. Align Up/Down is button-based (swaps the selected file with its neighbor),
|
the Episodes panel (series search + season browse with a Copy list export, no
|
||||||
not the original's drag-and-drop-within-a-list interaction.
|
credentials of its own, see "Episodes" above), the SFV panel (compute/verify
|
||||||
|
CRC32/MD5/SHA1 checksums, read/write `.sfv`/`.md5`/`.sha1` sidecar files, entirely
|
||||||
|
local with no credentials at all, see "SFV" above), the Filter panel (extension/
|
||||||
|
kind/filename/season narrowing over a locally-scanned batch, no new Rust/Tauri
|
||||||
|
surface at all, see "Filter" above), and the List panel (a naming-scheme-style
|
||||||
|
expression rendered per file via the same `render_expression` engine, with a Copy
|
||||||
|
List clipboard export and an Export to File… `.txt` writer behind one new
|
||||||
|
`export_text_list` command, see "List" above) — **all six of the original sidebar
|
||||||
|
panels are now real screens**, closing out the panel set FileBot itself shipped.
|
||||||
|
Align Up/Down is button-based
|
||||||
|
(swaps the selected file with its neighbor), not the original's
|
||||||
|
drag-and-drop-within-a-list interaction.
|
||||||
8. **Undo/history for renames** — done, as a flat JSON-backed undo stack rather than a
|
8. **Undo/history for renames** — done, as a flat JSON-backed undo stack rather than a
|
||||||
full port of `net.filebot.HistoryDialog`'s session/element log
|
full port of `net.filebot.HistoryDialog`'s session/element log
|
||||||
(`crates/renamr-core/src/history`, `src-tauri/src/history.rs`). Every successful
|
(`crates/aurora-curator-core/src/history`, `src-tauri/src/history.rs`). Every successful
|
||||||
rename is recorded to a `history.json` in the OS app-config directory (same location
|
rename is recorded to a `history.json` in the OS app-config directory (same location
|
||||||
as `settings.json`); the History panel lists past batches and can revert one back to
|
as `settings.json`); the History panel lists past batches and can revert one back to
|
||||||
its original paths, skipping (never overwriting) any file whose original path now
|
its original paths, skipping (never overwriting) any file whose original path now
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "renamr-core"
|
name = "aurora-curator-core"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "Renamr core: naming-expression engine, media matching, and rename engine (UI-agnostic)."
|
description = "Aurora Curator core: naming-expression engine, media matching, and rename engine (UI-agnostic)."
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Embeddable scripting engine powering the {expression} naming-scheme language.
|
# Embeddable scripting engine powering the {expression} naming-scheme language.
|
||||||
@@ -25,6 +25,19 @@ regex = "1"
|
|||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
tokio = { version = "1", features = ["rt", "macros"] }
|
tokio = { version = "1", features = ["rt", "macros"] }
|
||||||
|
|
||||||
|
# Checksum algorithms for the SFV panel (crc32fast for .sfv files, md-5/sha1 for the
|
||||||
|
# .md5/.sha1 sidecar-file formats the original FileBot SFV panel also supports).
|
||||||
|
crc32fast = "1"
|
||||||
|
md-5 = "0.10"
|
||||||
|
sha1 = "0.10"
|
||||||
|
|
||||||
|
# Real media-file container parsing for the media-probing binding source (`probe`
|
||||||
|
# module) - reads actual resolution/duration/codec/frame-rate straight from a file's
|
||||||
|
# header, no ffmpeg/MediaInfo system dependency needed. `mp4` covers ISO-BMFF
|
||||||
|
# containers (.mp4/.m4v/.mov); `matroska` covers EBML/Matroska containers (.mkv/.webm).
|
||||||
|
mp4 = "0.14"
|
||||||
|
matroska = "0.30"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# Local HTTP mock server so the TMDB client's request/response handling can be unit
|
# Local HTTP mock server so the TMDB client's request/response handling can be unit
|
||||||
# tested without live network access or a real API key.
|
# tested without live network access or a real API key.
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
//! Pure argument-parsing and file-expansion logic for Aurora Curator's command-line
|
||||||
|
//! rename mode (`-rename ...`) - a port of FileBot's `net.filebot.cli.ArgumentBean`/
|
||||||
|
//! `CmdlineOperations` flag surface, letting the app be scripted instead of only driven
|
||||||
|
//! through the GUI.
|
||||||
|
//!
|
||||||
|
//! Deliberately split from the actual filesystem/network orchestration, which lives in
|
||||||
|
//! `src-tauri/src/cli.rs` (it needs the Tauri-side settings store for the saved TMDB
|
||||||
|
//! token, which this crate has no access to and shouldn't - see that module's doc
|
||||||
|
//! comment). Everything here is pure logic with no I/O beyond `expand_inputs`'s
|
||||||
|
//! directory walk, so it's fully unit-testable without a filesystem mock or a Tauri app
|
||||||
|
//! at all.
|
||||||
|
//!
|
||||||
|
//! Deliberately NOT ported, matching the project's existing FileBot-compatibility
|
||||||
|
//! philosophy of tracking real usage over completeness: FileBot's `--output <dir>` (the
|
||||||
|
//! rename engine, see `crate::rename`'s own module doc, is deliberately scoped to never
|
||||||
|
//! move a file outside its own source directory tree - relaxing that safety invariant
|
||||||
|
//! for `--output` was judged not worth it, especially since a naming scheme's own `/`
|
||||||
|
//! already covers "organize into a subfolder") and `-non-strict` (accepted for
|
||||||
|
//! script-compatibility so existing FileBot invocations don't hard-fail on it, but it's
|
||||||
|
//! a genuine no-op here - the local matcher has no confidence score to loosen, and
|
||||||
|
//! online disambiguation, see `crate::similarity::best_title_match`, already runs
|
||||||
|
//! unconditionally regardless of any flag).
|
||||||
|
|
||||||
|
use crate::rename::{ConflictPolicy, RenameAction};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Which online source `--db` should resolve TV episodes (and, for `Tmdb`/`Omdb`,
|
||||||
|
/// movies too - see `src-tauri/src/cli.rs`'s `run_rename`) against. Deliberately its
|
||||||
|
/// own small enum rather than reusing the Tauri crate's `settings::TvMetadataSource`/
|
||||||
|
/// `MovieMetadataSource` (this crate can't depend on that crate, and those enums
|
||||||
|
/// represent *persisted GUI preferences* anyway, not a per-invocation flag) - a CLI run
|
||||||
|
/// always states its source explicitly via `--db` when it wants one; it never falls
|
||||||
|
/// back to whatever the GUI's Fetch Data Settings last had selected.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TvDbChoice {
|
||||||
|
Tmdb,
|
||||||
|
Tvmaze,
|
||||||
|
Omdb,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fully parsed, valid `-rename` invocation.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct RenameCliArgs {
|
||||||
|
pub format: String,
|
||||||
|
pub db: Option<TvDbChoice>,
|
||||||
|
pub action: RenameAction,
|
||||||
|
/// `--action test` - print the plan, touch nothing on disk.
|
||||||
|
pub dry_run: bool,
|
||||||
|
pub conflict: ConflictPolicy,
|
||||||
|
/// `-non-strict` was passed - accepted, but see the module doc for why it's a no-op.
|
||||||
|
pub non_strict: bool,
|
||||||
|
pub inputs: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum CliInvocation {
|
||||||
|
/// No recognized CLI trigger flag was present at all - launch the normal GUI,
|
||||||
|
/// unchanged from before this feature existed.
|
||||||
|
Gui,
|
||||||
|
/// `-h`/`--help` was passed - print usage and exit, touching neither the GUI nor
|
||||||
|
/// any files (checked before, and independently of, `-rename`).
|
||||||
|
Help,
|
||||||
|
Rename(RenameCliArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CliError(pub String);
|
||||||
|
|
||||||
|
impl std::fmt::Display for CliError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The trigger flags that mean "this is a CLI invocation, don't launch the window" -
|
||||||
|
/// exposed separately so `src-tauri/src/main.rs` can decide GUI-vs-CLI with the exact
|
||||||
|
/// same predicate `parse_args` itself uses for its `Gui`/`Help` split, rather than
|
||||||
|
/// duplicating (and risking drifting from) the list of trigger flags.
|
||||||
|
pub fn looks_like_cli_invocation(args: &[String]) -> bool {
|
||||||
|
args.iter().any(|a| a == "-rename" || a == "-h" || a == "--help")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a full `-rename ...` command line (already stripped of `argv[0]`, the
|
||||||
|
/// executable's own path). Recognizes `-h`/`--help` first and independently of
|
||||||
|
/// `-rename`, so `aurora-curator -rename --help` still prints usage rather than
|
||||||
|
/// demanding a `--format`.
|
||||||
|
pub fn parse_args(args: &[String]) -> Result<CliInvocation, CliError> {
|
||||||
|
if args.iter().any(|a| a == "-h" || a == "--help") {
|
||||||
|
return Ok(CliInvocation::Help);
|
||||||
|
}
|
||||||
|
if !args.iter().any(|a| a == "-rename") {
|
||||||
|
return Ok(CliInvocation::Gui);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut format: Option<String> = None;
|
||||||
|
let mut db: Option<TvDbChoice> = None;
|
||||||
|
let mut action = RenameAction::Move;
|
||||||
|
let mut dry_run = false;
|
||||||
|
let mut conflict = ConflictPolicy::Skip;
|
||||||
|
let mut non_strict = false;
|
||||||
|
let mut inputs = Vec::new();
|
||||||
|
|
||||||
|
let mut i = 0;
|
||||||
|
while i < args.len() {
|
||||||
|
let arg = args[i].as_str();
|
||||||
|
match arg {
|
||||||
|
"-rename" => {}
|
||||||
|
"-non-strict" => non_strict = true,
|
||||||
|
"--format" => {
|
||||||
|
i += 1;
|
||||||
|
let value = args
|
||||||
|
.get(i)
|
||||||
|
.ok_or_else(|| CliError("--format needs a value".to_string()))?;
|
||||||
|
format = Some(value.clone());
|
||||||
|
}
|
||||||
|
"--db" => {
|
||||||
|
i += 1;
|
||||||
|
let value = args.get(i).ok_or_else(|| CliError("--db needs a value".to_string()))?;
|
||||||
|
db = Some(match value.to_ascii_lowercase().as_str() {
|
||||||
|
"tmdb" => TvDbChoice::Tmdb,
|
||||||
|
"tvmaze" => TvDbChoice::Tvmaze,
|
||||||
|
"omdb" => TvDbChoice::Omdb,
|
||||||
|
other => {
|
||||||
|
return Err(CliError(format!(
|
||||||
|
"unknown --db source \"{other}\" (expected tmdb, tvmaze, or omdb)"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"--action" => {
|
||||||
|
i += 1;
|
||||||
|
let value = args
|
||||||
|
.get(i)
|
||||||
|
.ok_or_else(|| CliError("--action needs a value".to_string()))?;
|
||||||
|
match value.to_ascii_lowercase().as_str() {
|
||||||
|
"move" => action = RenameAction::Move,
|
||||||
|
"copy" => action = RenameAction::Copy,
|
||||||
|
"symlink" => action = RenameAction::Symlink,
|
||||||
|
"test" => dry_run = true,
|
||||||
|
other => {
|
||||||
|
return Err(CliError(format!(
|
||||||
|
"unknown --action \"{other}\" (expected move, copy, symlink, or test)"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--conflict" => {
|
||||||
|
i += 1;
|
||||||
|
let value = args
|
||||||
|
.get(i)
|
||||||
|
.ok_or_else(|| CliError("--conflict needs a value".to_string()))?;
|
||||||
|
conflict = match value.to_ascii_lowercase().as_str() {
|
||||||
|
"skip" => ConflictPolicy::Skip,
|
||||||
|
"auto-number" | "autonumber" | "auto" => ConflictPolicy::AutoNumber,
|
||||||
|
"override" => ConflictPolicy::Override,
|
||||||
|
other => {
|
||||||
|
return Err(CliError(format!(
|
||||||
|
"unknown --conflict \"{other}\" (expected skip, auto-number, or override)"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
other if other.starts_with('-') && other != "-" => {
|
||||||
|
return Err(CliError(format!("unrecognized flag \"{other}\"")));
|
||||||
|
}
|
||||||
|
other => inputs.push(PathBuf::from(other)),
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let format = format.ok_or_else(|| CliError("-rename needs --format \"<naming scheme>\"".to_string()))?;
|
||||||
|
if inputs.is_empty() {
|
||||||
|
return Err(CliError("-rename needs at least one input file or folder".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CliInvocation::Rename(RenameCliArgs {
|
||||||
|
format,
|
||||||
|
db,
|
||||||
|
action,
|
||||||
|
dry_run,
|
||||||
|
conflict,
|
||||||
|
non_strict,
|
||||||
|
inputs,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_hidden(path: &Path) -> bool {
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.map(|n| n.starts_with('.'))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect(path: &Path, out: &mut Vec<PathBuf>) {
|
||||||
|
if path.is_file() {
|
||||||
|
out.push(path.to_path_buf());
|
||||||
|
} else if path.is_dir() {
|
||||||
|
if let Ok(entries) = std::fs::read_dir(path) {
|
||||||
|
let mut children: Vec<PathBuf> = entries
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.path())
|
||||||
|
.filter(|p| !is_hidden(p))
|
||||||
|
.collect();
|
||||||
|
children.sort();
|
||||||
|
for child in children {
|
||||||
|
collect(&child, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expands `inputs` (files and/or directories) into a flat, deduplicated, sorted list
|
||||||
|
/// of regular files - directories are walked recursively and hidden entries (dotfiles/
|
||||||
|
/// dotdirs) are skipped, mirroring `src-tauri/src/files.rs`'s `expand()` (used by the
|
||||||
|
/// GUI's own "Select Folder…" picker and drag-and-drop) so a CLI run and a GUI load of
|
||||||
|
/// the same folder produce the same file set. A path that doesn't exist at all is
|
||||||
|
/// silently skipped rather than erroring the whole batch - a typo'd path among several
|
||||||
|
/// good ones shouldn't block processing the rest.
|
||||||
|
pub fn expand_inputs(inputs: &[PathBuf]) -> Vec<PathBuf> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
for input in inputs {
|
||||||
|
collect(input, &mut files);
|
||||||
|
}
|
||||||
|
files.sort();
|
||||||
|
files.dedup();
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
|
||||||
|
fn s(text: &str) -> String {
|
||||||
|
text.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_trigger_flags_means_gui() {
|
||||||
|
// No `-rename`/`-h`/`--help` present at all: treated as a plain GUI launch,
|
||||||
|
// even with other stray args present (e.g. whatever the OS/launcher passes) -
|
||||||
|
// only once `-rename` itself is present does an unrecognized flag become an
|
||||||
|
// error (see `unrecognized_flag_errors_instead_of_being_treated_as_an_input`).
|
||||||
|
assert_eq!(parse_args(&[]).unwrap(), CliInvocation::Gui);
|
||||||
|
assert_eq!(parse_args(&[s("--version")]).unwrap(), CliInvocation::Gui);
|
||||||
|
assert_eq!(parse_args(&[s("some-random-arg")]).unwrap(), CliInvocation::Gui);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_wins_even_alongside_rename() {
|
||||||
|
assert_eq!(parse_args(&[s("--help")]).unwrap(), CliInvocation::Help);
|
||||||
|
assert_eq!(parse_args(&[s("-h")]).unwrap(), CliInvocation::Help);
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(&[s("-rename"), s("--help")]).unwrap(),
|
||||||
|
CliInvocation::Help
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_requires_format() {
|
||||||
|
let err = parse_args(&[s("-rename"), s("file.mkv")]).unwrap_err();
|
||||||
|
assert!(err.0.contains("--format"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_requires_at_least_one_input() {
|
||||||
|
let err = parse_args(&[s("-rename"), s("--format"), s("{n}")]).unwrap_err();
|
||||||
|
assert!(err.0.contains("input"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn minimal_valid_invocation_defaults_move_and_skip() {
|
||||||
|
let parsed = parse_args(&[s("-rename"), s("--format"), s("{n}"), s("file.mkv")]).unwrap();
|
||||||
|
match parsed {
|
||||||
|
CliInvocation::Rename(args) => {
|
||||||
|
assert_eq!(args.format, "{n}");
|
||||||
|
assert_eq!(args.db, None);
|
||||||
|
assert_eq!(args.action, RenameAction::Move);
|
||||||
|
assert_eq!(args.dry_run, false);
|
||||||
|
assert_eq!(args.conflict, ConflictPolicy::Skip);
|
||||||
|
assert_eq!(args.non_strict, false);
|
||||||
|
assert_eq!(args.inputs, vec![PathBuf::from("file.mkv")]);
|
||||||
|
}
|
||||||
|
other => panic!("expected Rename, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn db_is_case_insensitive_and_rejects_unknown_sources() {
|
||||||
|
let parsed = parse_args(&[
|
||||||
|
s("-rename"),
|
||||||
|
s("--format"),
|
||||||
|
s("{n}"),
|
||||||
|
s("--db"),
|
||||||
|
s("TVMAZE"),
|
||||||
|
s("file.mkv"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
match parsed {
|
||||||
|
CliInvocation::Rename(args) => assert_eq!(args.db, Some(TvDbChoice::Tvmaze)),
|
||||||
|
other => panic!("expected Rename, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = parse_args(&[
|
||||||
|
s("-rename"),
|
||||||
|
s("--format"),
|
||||||
|
s("{n}"),
|
||||||
|
s("--db"),
|
||||||
|
s("OMDB"),
|
||||||
|
s("file.mkv"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
match parsed {
|
||||||
|
CliInvocation::Rename(args) => assert_eq!(args.db, Some(TvDbChoice::Omdb)),
|
||||||
|
other => panic!("expected Rename, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let err = parse_args(&[
|
||||||
|
s("-rename"),
|
||||||
|
s("--format"),
|
||||||
|
s("{n}"),
|
||||||
|
s("--db"),
|
||||||
|
s("thetvdb"),
|
||||||
|
s("file.mkv"),
|
||||||
|
])
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.0.contains("thetvdb"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn action_test_sets_dry_run_without_changing_the_conflict_policy() {
|
||||||
|
let parsed = parse_args(&[
|
||||||
|
s("-rename"),
|
||||||
|
s("--format"),
|
||||||
|
s("{n}"),
|
||||||
|
s("--action"),
|
||||||
|
s("test"),
|
||||||
|
s("file.mkv"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
match parsed {
|
||||||
|
CliInvocation::Rename(args) => {
|
||||||
|
assert_eq!(args.dry_run, true);
|
||||||
|
assert_eq!(args.action, RenameAction::Move);
|
||||||
|
}
|
||||||
|
other => panic!("expected Rename, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conflict_accepts_filebot_style_aliases() {
|
||||||
|
for (alias, expected) in [
|
||||||
|
("skip", ConflictPolicy::Skip),
|
||||||
|
("auto-number", ConflictPolicy::AutoNumber),
|
||||||
|
("autonumber", ConflictPolicy::AutoNumber),
|
||||||
|
("auto", ConflictPolicy::AutoNumber),
|
||||||
|
("override", ConflictPolicy::Override),
|
||||||
|
] {
|
||||||
|
let parsed = parse_args(&[
|
||||||
|
s("-rename"),
|
||||||
|
s("--format"),
|
||||||
|
s("{n}"),
|
||||||
|
s("--conflict"),
|
||||||
|
s(alias),
|
||||||
|
s("file.mkv"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
match parsed {
|
||||||
|
CliInvocation::Rename(args) => assert_eq!(args.conflict, expected, "alias {alias}"),
|
||||||
|
other => panic!("expected Rename, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_strict_flag_is_recorded_but_does_not_consume_a_value() {
|
||||||
|
let parsed = parse_args(&[
|
||||||
|
s("-rename"),
|
||||||
|
s("-non-strict"),
|
||||||
|
s("--format"),
|
||||||
|
s("{n}"),
|
||||||
|
s("file.mkv"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
match parsed {
|
||||||
|
CliInvocation::Rename(args) => {
|
||||||
|
assert_eq!(args.non_strict, true);
|
||||||
|
assert_eq!(args.inputs, vec![PathBuf::from("file.mkv")]);
|
||||||
|
}
|
||||||
|
other => panic!("expected Rename, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unrecognized_flag_errors_instead_of_being_treated_as_an_input() {
|
||||||
|
let err = parse_args(&[
|
||||||
|
s("-rename"),
|
||||||
|
s("--format"),
|
||||||
|
s("{n}"),
|
||||||
|
s("--output"),
|
||||||
|
s("/tmp"),
|
||||||
|
s("file.mkv"),
|
||||||
|
])
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.0.contains("--output"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_inputs_are_all_collected() {
|
||||||
|
let parsed = parse_args(&[
|
||||||
|
s("-rename"),
|
||||||
|
s("--format"),
|
||||||
|
s("{n}"),
|
||||||
|
s("a.mkv"),
|
||||||
|
s("b.mkv"),
|
||||||
|
s("c.mkv"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
match parsed {
|
||||||
|
CliInvocation::Rename(args) => {
|
||||||
|
assert_eq!(
|
||||||
|
args.inputs,
|
||||||
|
vec![PathBuf::from("a.mkv"), PathBuf::from("b.mkv"), PathBuf::from("c.mkv")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected Rename, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||||
|
|
||||||
|
fn temp_dir() -> PathBuf {
|
||||||
|
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let dir = std::env::temp_dir().join(format!("aurora_cli_test_{n}_{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_inputs_walks_directories_recursively_and_skips_hidden_entries() {
|
||||||
|
let root = temp_dir();
|
||||||
|
std::fs::write(root.join("top.mkv"), b"").unwrap();
|
||||||
|
std::fs::write(root.join(".hidden.mkv"), b"").unwrap();
|
||||||
|
std::fs::create_dir_all(root.join("season 1")).unwrap();
|
||||||
|
std::fs::write(root.join("season 1").join("e01.mkv"), b"").unwrap();
|
||||||
|
std::fs::create_dir_all(root.join(".hidden_dir")).unwrap();
|
||||||
|
std::fs::write(root.join(".hidden_dir").join("e02.mkv"), b"").unwrap();
|
||||||
|
|
||||||
|
let files = expand_inputs(&[root.clone()]);
|
||||||
|
|
||||||
|
assert_eq!(files.len(), 2, "found: {files:?}");
|
||||||
|
assert!(files.contains(&root.join("top.mkv")));
|
||||||
|
assert!(files.contains(&root.join("season 1").join("e01.mkv")));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_inputs_accepts_a_bare_file_and_dedupes_overlapping_inputs() {
|
||||||
|
let root = temp_dir();
|
||||||
|
let file = root.join("only.mkv");
|
||||||
|
std::fs::write(&file, b"").unwrap();
|
||||||
|
|
||||||
|
let files = expand_inputs(&[file.clone(), file.clone(), root.clone()]);
|
||||||
|
assert_eq!(files, vec![file]);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_inputs_silently_skips_a_nonexistent_path() {
|
||||||
|
let files = expand_inputs(&[PathBuf::from("/definitely/not/a/real/path/anywhere")]);
|
||||||
|
assert_eq!(files, Vec::<PathBuf>::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Renamr's naming-scheme expression language.
|
//! Aurora Curator's naming-scheme expression language.
|
||||||
//!
|
//!
|
||||||
//! A format string is literal text with `{ ... }` scriptlets spliced in, e.g.
|
//! A format string is literal text with `{ ... }` scriptlets spliced in, e.g.
|
||||||
//! `"{n} - {s00e00} - {t}"`
|
//! `"{n} - {s00e00} - {t}"`
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//! Rename history: a persisted log of past rename batches (what changed, from what to
|
//! Rename history: a persisted log of past rename batches (what changed, from what to
|
||||||
//! what) and the ability to revert one - Renamr's only safety net beyond the
|
//! what) and the ability to revert one - Aurora Curator's only safety net beyond the
|
||||||
//! confirmation-before-execute step, since a plain filesystem rename has no OS-level
|
//! confirmation-before-execute step, since a plain filesystem rename has no OS-level
|
||||||
//! undo. Loosely inspired by `net.filebot.HistoryDialog`/the original's rename-history
|
//! undo. Loosely inspired by `net.filebot.HistoryDialog`/the original's rename-history
|
||||||
//! log, but as a flat JSON-backed undo stack (see [`push_batch`]) rather than a full
|
//! log, but as a flat JSON-backed undo stack (see [`push_batch`]) rather than a full
|
||||||
@@ -93,7 +93,7 @@ fn revert_one(entry: &HistoryEntry, action: RenameAction) -> RevertOutcome {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// The original's parent directory should still exist (nothing in Renamr
|
// The original's parent directory should still exist (nothing in Aurora Curator
|
||||||
// deletes directories), but recreate it defensively in case it was removed
|
// deletes directories), but recreate it defensively in case it was removed
|
||||||
// by hand since - cheap, and `create_dir_all` is a no-op when it's already
|
// by hand since - cheap, and `create_dir_all` is a no-op when it's already
|
||||||
// there.
|
// there.
|
||||||
@@ -175,7 +175,7 @@ mod tests {
|
|||||||
|
|
||||||
fn temp_dir() -> std::path::PathBuf {
|
fn temp_dir() -> std::path::PathBuf {
|
||||||
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
|
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||||
let dir = std::env::temp_dir().join(format!("renamr_history_test_{}_{}", std::process::id(), n));
|
let dir = std::env::temp_dir().join(format!("aurora_curator_history_test_{}_{}", std::process::id(), n));
|
||||||
fs::create_dir_all(&dir).unwrap();
|
fs::create_dir_all(&dir).unwrap();
|
||||||
dir
|
dir
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
//! Renamr core: the UI-agnostic engine behind the Renamr media renamer.
|
//! Aurora Curator core: the UI-agnostic engine behind the Aurora Curator media renamer.
|
||||||
//!
|
//!
|
||||||
//! This crate has no Tauri/UI dependency so it can be built and unit-tested standalone.
|
//! This crate has no Tauri/UI dependency so it can be built and unit-tested standalone.
|
||||||
//! It's the piece being ported first from the original FileBot (Java) codebase; see
|
//! It's the piece being ported first from the original FileBot (Java) codebase; see
|
||||||
//! the workspace README for the porting roadmap (matching engine, media metadata
|
//! the workspace README for the porting roadmap (matching engine, media metadata
|
||||||
//! parsing, web datasource clients, rename engine are not yet ported).
|
//! parsing, web datasource clients, rename engine are not yet ported).
|
||||||
|
|
||||||
|
pub mod cli;
|
||||||
pub mod format;
|
pub mod format;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod matching;
|
pub mod matching;
|
||||||
|
pub mod probe;
|
||||||
pub mod rename;
|
pub mod rename;
|
||||||
|
pub mod sfv;
|
||||||
pub mod similarity;
|
pub mod similarity;
|
||||||
pub mod web;
|
pub mod web;
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ pub fn guess_movie(filename: &str) -> Option<MovieGuess> {
|
|||||||
/// (capture/rip type), video codec, audio codec, and release group. Ports the
|
/// (capture/rip type), video codec, audio codec, and release group. Ports the
|
||||||
/// filename-parsing half of the original FileBot's `ReleaseInfo` class - the same
|
/// filename-parsing half of the original FileBot's `ReleaseInfo` class - the same
|
||||||
/// regex-based tag recognition `MediaBindingBean` falls back to when no real media
|
/// regex-based tag recognition `MediaBindingBean` falls back to when no real media
|
||||||
/// probe (ffprobe/MediaInfo) is available, which Renamr never has, so this is the
|
/// probe (ffprobe/MediaInfo) is available, which Aurora Curator never has, so this is the
|
||||||
/// only source for these fields here. Deliberately independent of `guess_episode`/
|
/// only source for these fields here. Deliberately independent of `guess_episode`/
|
||||||
/// `guess_movie`: a release name carries these tags (or doesn't) regardless of
|
/// `guess_movie`: a release name carries these tags (or doesn't) regardless of
|
||||||
/// whether it's a TV episode or a movie, so callers run this alongside whichever of
|
/// whether it's a TV episode or a movie, so callers run this alongside whichever of
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
//! Real media-file probing: reads actual container metadata (resolution, duration,
|
||||||
|
//! frame rate, codecs) straight from a video file's own header, rather than guessing
|
||||||
|
//! from the filename the way `matching::guess_release_info` does. This is the piece
|
||||||
|
//! the README's porting roadmap calls out as still missing from `MediaBindingBean`
|
||||||
|
//! parity - "everything MediaBindingBean derives from actually probing the media
|
||||||
|
//! file" - and it's meant to complement filename-tag guessing, not replace it: the
|
||||||
|
//! caller (the Rename panel's binding merge) only uses a probed value for a field the
|
||||||
|
//! filename didn't already carry a tag for.
|
||||||
|
//!
|
||||||
|
//! No ffmpeg/MediaInfo system dependency is needed - two pure-Rust container parsers
|
||||||
|
//! do the actual reading: the `mp4` crate for ISO-BMFF containers (.mp4/.m4v/.mov)
|
||||||
|
//! and the `matroska` crate for EBML/Matroska containers (.mkv/.webm). Anything else
|
||||||
|
//! (.avi, .wmv, an unrecognized extension, or a file that fails to parse as its
|
||||||
|
//! container) yields a [`MediaProbe`] with only `file_size_bytes` populated - probing
|
||||||
|
//! is always a best-effort enrichment layered on top of filename matching, never a
|
||||||
|
//! hard requirement, so a corrupt or unsupported file must never error the caller out.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Everything real container probing could determine about one media file. Every
|
||||||
|
/// field but `file_size_bytes` is `None` when the file isn't a supported container,
|
||||||
|
/// couldn't be parsed, or simply doesn't carry that particular piece of information
|
||||||
|
/// (e.g. a video track with no frame-rate box).
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MediaProbe {
|
||||||
|
pub file_size_bytes: u64,
|
||||||
|
pub duration_seconds: Option<f64>,
|
||||||
|
pub width: Option<u32>,
|
||||||
|
pub height: Option<u32>,
|
||||||
|
pub frame_rate: Option<f64>,
|
||||||
|
pub video_codec: Option<String>,
|
||||||
|
pub audio_codec: Option<String>,
|
||||||
|
pub container: Option<&'static str>,
|
||||||
|
// Pre-formatted display labels for the three fields above that a naming-scheme
|
||||||
|
// binding actually wants human-readable rather than raw - computed here (not in
|
||||||
|
// the UI layer) so there's exactly one place that knows how to format them, shared
|
||||||
|
// by every caller. `resolution` mirrors `matching::ReleaseInfo::resolution`'s
|
||||||
|
// "1080p"-style bucketing so a probed value and a filename-tag value read
|
||||||
|
// identically; `file_size` is always present since `file_size_bytes` always is.
|
||||||
|
pub resolution: Option<String>,
|
||||||
|
pub duration: Option<String>,
|
||||||
|
pub file_size: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probes one file by its extension. Never fails - a file that can't be opened, isn't
|
||||||
|
/// a recognized container, or fails to parse just yields a mostly-empty [`MediaProbe`]
|
||||||
|
/// (still carrying a real `file_size_bytes`/`file_size` when the file itself is readable).
|
||||||
|
pub fn probe_media_file(path: &Path) -> MediaProbe {
|
||||||
|
let file_size_bytes = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||||
|
let mut probe = MediaProbe {
|
||||||
|
file_size_bytes,
|
||||||
|
file_size: file_size_label(file_size_bytes),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||||
|
match ext.as_deref() {
|
||||||
|
Some("mp4") | Some("m4v") | Some("mov") => probe_mp4(path, &mut probe),
|
||||||
|
Some("mkv") | Some("webm") => probe_matroska(path, &mut probe),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(height) = probe.height {
|
||||||
|
probe.resolution = Some(resolution_label(height).to_string());
|
||||||
|
}
|
||||||
|
if let Some(seconds) = probe.duration_seconds {
|
||||||
|
probe.duration = Some(duration_label(seconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
probe
|
||||||
|
}
|
||||||
|
|
||||||
|
fn probe_mp4(path: &Path, probe: &mut MediaProbe) {
|
||||||
|
let Ok(file) = std::fs::File::open(path) else { return };
|
||||||
|
let Ok(size) = file.metadata().map(|m| m.len()) else { return };
|
||||||
|
let reader = std::io::BufReader::new(file);
|
||||||
|
let Ok(reader) = mp4::Mp4Reader::read_header(reader, size) else { return };
|
||||||
|
|
||||||
|
probe.container = Some("MP4");
|
||||||
|
let total = reader.duration();
|
||||||
|
if total > Duration::ZERO {
|
||||||
|
probe.duration_seconds = Some(total.as_secs_f64());
|
||||||
|
}
|
||||||
|
|
||||||
|
for track in reader.tracks().values() {
|
||||||
|
match track.track_type() {
|
||||||
|
Ok(mp4::TrackType::Video) => {
|
||||||
|
let (w, h) = (track.width(), track.height());
|
||||||
|
if w > 0 && h > 0 {
|
||||||
|
probe.width = Some(w as u32);
|
||||||
|
probe.height = Some(h as u32);
|
||||||
|
}
|
||||||
|
let fps = track.frame_rate();
|
||||||
|
if fps > 0.0 {
|
||||||
|
probe.frame_rate = Some(fps);
|
||||||
|
}
|
||||||
|
if let Ok(media_type) = track.media_type() {
|
||||||
|
probe.video_codec = Some(mp4_codec_label(media_type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(mp4::TrackType::Audio) => {
|
||||||
|
if probe.audio_codec.is_none() {
|
||||||
|
if let Ok(media_type) = track.media_type() {
|
||||||
|
probe.audio_codec = Some(mp4_codec_label(media_type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn probe_matroska(path: &Path, probe: &mut MediaProbe) {
|
||||||
|
let Ok(mat) = matroska::open(path) else { return };
|
||||||
|
|
||||||
|
probe.container = Some("Matroska");
|
||||||
|
if let Some(duration) = mat.info.duration {
|
||||||
|
probe.duration_seconds = Some(duration.as_secs_f64());
|
||||||
|
}
|
||||||
|
|
||||||
|
for track in &mat.tracks {
|
||||||
|
match &track.settings {
|
||||||
|
matroska::Settings::Video(video) => {
|
||||||
|
if video.pixel_width > 0 && video.pixel_height > 0 {
|
||||||
|
probe.width = Some(video.pixel_width as u32);
|
||||||
|
probe.height = Some(video.pixel_height as u32);
|
||||||
|
}
|
||||||
|
probe.video_codec = Some(codec_label(&track.codec_id));
|
||||||
|
}
|
||||||
|
matroska::Settings::Audio(_) => {
|
||||||
|
if probe.audio_codec.is_none() {
|
||||||
|
probe.audio_codec = Some(codec_label(&track.codec_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matroska::Settings::None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Friendly label for an mp4 track's [`mp4::MediaType`] - matches the short labels
|
||||||
|
/// `codec_label` below produces from a Matroska codec ID, so a probed codec reads the
|
||||||
|
/// same way regardless of which container it came from.
|
||||||
|
fn mp4_codec_label(media_type: mp4::MediaType) -> String {
|
||||||
|
match media_type {
|
||||||
|
mp4::MediaType::H264 => "H.264".to_string(),
|
||||||
|
mp4::MediaType::H265 => "H.265".to_string(),
|
||||||
|
mp4::MediaType::VP9 => "VP9".to_string(),
|
||||||
|
mp4::MediaType::AAC => "AAC".to_string(),
|
||||||
|
mp4::MediaType::TTXT => "Text".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Matroska codec IDs (see <https://www.matroska.org/technical/codec_specs.html>) are
|
||||||
|
/// machine-readable strings like `"V_MPEG4/ISO/AVC"` rather than a friendly name - map
|
||||||
|
/// the ones scene releases actually use to the same short labels
|
||||||
|
/// `matching::guess_release_info` already produces from filename tags. An unrecognized
|
||||||
|
/// codec ID is passed through as-is rather than dropped, so it's still visible even if
|
||||||
|
/// not prettified.
|
||||||
|
fn codec_label(codec_id: &str) -> String {
|
||||||
|
match codec_id {
|
||||||
|
"V_MPEG4/ISO/AVC" => "H.264".to_string(),
|
||||||
|
"V_MPEGH/ISO/HEVC" => "H.265".to_string(),
|
||||||
|
"V_VP9" => "VP9".to_string(),
|
||||||
|
"V_VP8" => "VP8".to_string(),
|
||||||
|
"V_AV1" => "AV1".to_string(),
|
||||||
|
"V_MPEG4/ISO/ASP" => "XviD".to_string(),
|
||||||
|
"A_AAC" | "A_AAC/MPEG4/LC" => "AAC".to_string(),
|
||||||
|
"A_AC3" => "AC3".to_string(),
|
||||||
|
"A_EAC3" => "E-AC3".to_string(),
|
||||||
|
"A_DTS" => "DTS".to_string(),
|
||||||
|
"A_TRUEHD" => "TrueHD".to_string(),
|
||||||
|
"A_FLAC" => "FLAC".to_string(),
|
||||||
|
"A_OPUS" => "Opus".to_string(),
|
||||||
|
"A_VORBIS" => "Vorbis".to_string(),
|
||||||
|
"A_MPEG/L3" => "MP3".to_string(),
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Buckets a real probed pixel height into the same standard-resolution labels
|
||||||
|
/// `matching::guess_release_info`'s filename-tag parsing recognizes (`"1080p"`,
|
||||||
|
/// `"720p"`, ...), so a probed resolution and a filename-tag resolution read
|
||||||
|
/// identically in a naming scheme's `{resolution}`/`{vf}` binding.
|
||||||
|
pub fn resolution_label(height: u32) -> &'static str {
|
||||||
|
if height >= 2000 {
|
||||||
|
"2160p"
|
||||||
|
} else if height >= 1000 {
|
||||||
|
"1080p"
|
||||||
|
} else if height >= 700 {
|
||||||
|
"720p"
|
||||||
|
} else if height >= 540 {
|
||||||
|
"576p"
|
||||||
|
} else if height >= 400 {
|
||||||
|
"480p"
|
||||||
|
} else {
|
||||||
|
"SD"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats a duration in seconds as `h:mm:ss` (or `m:ss` under an hour), for a
|
||||||
|
/// `{duration}` binding.
|
||||||
|
pub fn duration_label(seconds: f64) -> String {
|
||||||
|
let total = seconds.round() as u64;
|
||||||
|
let h = total / 3600;
|
||||||
|
let m = (total % 3600) / 60;
|
||||||
|
let s = total % 60;
|
||||||
|
if h > 0 {
|
||||||
|
format!("{h}:{m:02}:{s:02}")
|
||||||
|
} else {
|
||||||
|
format!("{m}:{s:02}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats a byte count as a human-readable size (`"1.4 GB"`, `"512 MB"`, ...), for a
|
||||||
|
/// `{file size}` binding.
|
||||||
|
pub fn file_size_label(bytes: u64) -> String {
|
||||||
|
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let mut size = bytes as f64;
|
||||||
|
let mut unit = 0;
|
||||||
|
while size >= 1024.0 && unit < UNITS.len() - 1 {
|
||||||
|
size /= 1024.0;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
if unit == 0 {
|
||||||
|
format!("{bytes} {}", UNITS[unit])
|
||||||
|
} else {
|
||||||
|
format!("{size:.1} {}", UNITS[unit])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn fixture(name: &str) -> std::path::PathBuf {
|
||||||
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probes_a_real_mp4_file() {
|
||||||
|
let probe = probe_media_file(&fixture("sample.mp4"));
|
||||||
|
assert_eq!(probe.container, Some("MP4"));
|
||||||
|
assert_eq!(probe.width, Some(320));
|
||||||
|
assert_eq!(probe.height, Some(240));
|
||||||
|
assert_eq!(probe.video_codec.as_deref(), Some("H.264"));
|
||||||
|
assert_eq!(probe.audio_codec.as_deref(), Some("AAC"));
|
||||||
|
assert!(probe.duration_seconds.unwrap() > 0.5);
|
||||||
|
assert!(probe.file_size_bytes > 0);
|
||||||
|
assert_eq!(probe.resolution.as_deref(), Some("SD")); // 240px tall - below the 480p bucket
|
||||||
|
assert_eq!(probe.duration.as_deref(), Some("0:01"));
|
||||||
|
assert!(!probe.file_size.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probes_a_real_matroska_file() {
|
||||||
|
let probe = probe_media_file(&fixture("sample.mkv"));
|
||||||
|
assert_eq!(probe.container, Some("Matroska"));
|
||||||
|
assert_eq!(probe.width, Some(640));
|
||||||
|
assert_eq!(probe.height, Some(360));
|
||||||
|
assert_eq!(probe.video_codec.as_deref(), Some("H.264"));
|
||||||
|
assert_eq!(probe.audio_codec.as_deref(), Some("AAC"));
|
||||||
|
assert!(probe.duration_seconds.unwrap() > 0.5);
|
||||||
|
assert_eq!(probe.resolution.as_deref(), Some("SD")); // 360px tall - below the 480p bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_media_file_yields_only_file_size() {
|
||||||
|
let probe = probe_media_file(&fixture("not_media.txt"));
|
||||||
|
assert_eq!(probe.container, None);
|
||||||
|
assert_eq!(probe.width, None);
|
||||||
|
assert_eq!(probe.resolution, None);
|
||||||
|
assert_eq!(probe.duration, None);
|
||||||
|
assert!(probe.file_size_bytes > 0);
|
||||||
|
assert!(!probe.file_size.is_empty()); // file_size is always computed from the real byte count
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_container_degrades_gracefully() {
|
||||||
|
let probe = probe_media_file(&fixture("corrupt.mp4"));
|
||||||
|
assert_eq!(probe.container, None);
|
||||||
|
assert_eq!(probe.width, None);
|
||||||
|
assert!(probe.file_size_bytes > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_file_yields_all_none() {
|
||||||
|
let probe = probe_media_file(Path::new("/does/not/exist.mp4"));
|
||||||
|
assert_eq!(probe.file_size_bytes, 0);
|
||||||
|
assert_eq!(probe.container, None);
|
||||||
|
assert_eq!(probe.file_size, "0 B");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolution_label_buckets_common_heights() {
|
||||||
|
assert_eq!(resolution_label(2160), "2160p");
|
||||||
|
assert_eq!(resolution_label(1080), "1080p");
|
||||||
|
assert_eq!(resolution_label(720), "720p");
|
||||||
|
assert_eq!(resolution_label(480), "480p");
|
||||||
|
assert_eq!(resolution_label(240), "SD");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duration_label_formats_with_and_without_hours() {
|
||||||
|
assert_eq!(duration_label(45.0), "0:45");
|
||||||
|
assert_eq!(duration_label(125.0), "2:05");
|
||||||
|
assert_eq!(duration_label(3725.0), "1:02:05");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_size_label_formats_common_magnitudes() {
|
||||||
|
assert_eq!(file_size_label(512), "512 B");
|
||||||
|
assert_eq!(file_size_label(1536), "1.5 KB");
|
||||||
|
assert_eq!(file_size_label(1_500_000_000), "1.4 GB");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
//! the caller a confirmation of the full plan (and let them choose both a
|
//! the caller a confirmation of the full plan (and let them choose both a
|
||||||
//! [`RenameAction`] and a [`ConflictPolicy`]) before calling [`execute_renames`]; every
|
//! [`RenameAction`] and a [`ConflictPolicy`]) before calling [`execute_renames`]; every
|
||||||
//! batch that action actually touches a file for is also recorded to the rename-history
|
//! batch that action actually touches a file for is also recorded to the rename-history
|
||||||
//! log one layer up (`renamr_core::history`), so it can be undone from there. Undoing
|
//! log one layer up (`aurora_curator_core::history`), so it can be undone from there. Undoing
|
||||||
//! always uses skip-on-conflict regardless of what policy the original rename used
|
//! always uses skip-on-conflict regardless of what policy the original rename used
|
||||||
//! (see `history::revert_one`) - overwriting an unrelated file as a side effect of an
|
//! (see `history::revert_one`) - overwriting an unrelated file as a side effect of an
|
||||||
//! *undo* would be a much worse surprise than leaving it for the user to sort out by
|
//! *undo* would be a much worse surprise than leaving it for the user to sort out by
|
||||||
@@ -47,7 +47,7 @@ pub struct RenamePlanItem {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub enum RenameAction {
|
pub enum RenameAction {
|
||||||
/// Renames/moves the file in place - the only action Renamr had before this. The
|
/// Renames/moves the file in place - the only action Aurora Curator had before this. The
|
||||||
/// source file no longer exists afterward; the item now lives at the target path.
|
/// source file no longer exists afterward; the item now lives at the target path.
|
||||||
Move,
|
Move,
|
||||||
/// Copies the file's contents to the target path, leaving the source untouched.
|
/// Copies the file's contents to the target path, leaving the source untouched.
|
||||||
@@ -118,7 +118,7 @@ pub struct RenameResult {
|
|||||||
/// no empty components (a leading, trailing, or doubled `/` - which also catches an
|
/// no empty components (a leading, trailing, or doubled `/` - which also catches an
|
||||||
/// absolute-looking `/etc/passwd`-style path, since splitting it on `/` yields a
|
/// absolute-looking `/etc/passwd`-style path, since splitting it on `/` yields a
|
||||||
/// leading empty component), and no `.`/`..` components. Backslashes are rejected
|
/// leading empty component), and no `.`/`..` components. Backslashes are rejected
|
||||||
/// outright rather than treated as an alternate separator - Renamr always uses `/` for
|
/// outright rather than treated as an alternate separator - Aurora Curator always uses `/` for
|
||||||
/// the naming scheme's folder separator regardless of OS, so a literal `\` in a
|
/// the naming scheme's folder separator regardless of OS, so a literal `\` in a
|
||||||
/// computed name is almost certainly a mistake, not an intentional Windows path.
|
/// computed name is almost certainly a mistake, not an intentional Windows path.
|
||||||
fn validate_relative_target(name: &str) -> Result<(), &'static str> {
|
fn validate_relative_target(name: &str) -> Result<(), &'static str> {
|
||||||
@@ -322,7 +322,7 @@ mod tests {
|
|||||||
/// the real filesystem. Returns (dir, file_path).
|
/// the real filesystem. Returns (dir, file_path).
|
||||||
fn temp_file_setup(file_name: &str, contents: &str) -> (std::path::PathBuf, std::path::PathBuf) {
|
fn temp_file_setup(file_name: &str, contents: &str) -> (std::path::PathBuf, std::path::PathBuf) {
|
||||||
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
|
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||||
let dir = std::env::temp_dir().join(format!("renamr_rename_test_{}_{}", std::process::id(), n));
|
let dir = std::env::temp_dir().join(format!("aurora_curator_rename_test_{}_{}", std::process::id(), n));
|
||||||
fs::create_dir_all(&dir).unwrap();
|
fs::create_dir_all(&dir).unwrap();
|
||||||
let file = dir.join(file_name);
|
let file = dir.join(file_name);
|
||||||
fs::write(&file, contents).unwrap();
|
fs::write(&file, contents).unwrap();
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
//! Checksum computation and verification ("SFV" - Simple File Verification), a partial
|
||||||
|
//! port of `net.filebot.ui.sfv.SfvPanel`. Computes CRC32/MD5/SHA-1 checksums for a set
|
||||||
|
//! of files, builds a sidecar checksum-file's text content (`.sfv`/`.md5`/`.sha1`), and
|
||||||
|
//! verifies a set of files against an already-parsed checksum file - flagging each
|
||||||
|
//! referenced file OK, mismatched, or missing on disk. Deliberately local/offline only,
|
||||||
|
//! unlike every other feature added this session - no web client, no credentials.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{self, Read};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use md5::{Digest, Md5};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha1::Sha1;
|
||||||
|
|
||||||
|
const READ_BUFFER_SIZE: usize = 64 * 1024;
|
||||||
|
|
||||||
|
/// Which checksum algorithm/file format is in play. `Crc32` is the traditional ".sfv"
|
||||||
|
/// format (whole-file CRC32, 8 uppercase hex digits); `Md5`/`Sha1` follow the GNU
|
||||||
|
/// coreutils `md5sum`/`sha1sum` sidecar-file convention instead (`<hex> <filename>`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ChecksumKind {
|
||||||
|
Crc32,
|
||||||
|
Md5,
|
||||||
|
Sha1,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChecksumKind {
|
||||||
|
/// The sidecar file extension conventionally used for this kind (no leading dot).
|
||||||
|
pub fn extension(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ChecksumKind::Crc32 => "sfv",
|
||||||
|
ChecksumKind::Md5 => "md5",
|
||||||
|
ChecksumKind::Sha1 => "sha1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A human-readable label for UI display.
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ChecksumKind::Crc32 => "CRC32",
|
||||||
|
ChecksumKind::Md5 => "MD5",
|
||||||
|
ChecksumKind::Sha1 => "SHA-1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ChecksumKind {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(self.label())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streams `path` and returns its checksum as hex text - uppercase for CRC32 (matching
|
||||||
|
/// traditional `.sfv` file convention), lowercase for MD5/SHA-1 (matching
|
||||||
|
/// `md5sum`/`sha1sum`). Streams in fixed-size chunks rather than reading the whole file
|
||||||
|
/// into memory at once, so this scales to large video files without a huge allocation.
|
||||||
|
pub fn compute_checksum(path: &Path, kind: ChecksumKind) -> io::Result<String> {
|
||||||
|
let mut file = File::open(path)?;
|
||||||
|
let mut buf = [0u8; READ_BUFFER_SIZE];
|
||||||
|
|
||||||
|
match kind {
|
||||||
|
ChecksumKind::Crc32 => {
|
||||||
|
let mut hasher = crc32fast::Hasher::new();
|
||||||
|
loop {
|
||||||
|
let n = file.read(&mut buf)?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buf[..n]);
|
||||||
|
}
|
||||||
|
Ok(format!("{:08X}", hasher.finalize()))
|
||||||
|
}
|
||||||
|
ChecksumKind::Md5 => {
|
||||||
|
let mut hasher = Md5::new();
|
||||||
|
loop {
|
||||||
|
let n = file.read(&mut buf)?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buf[..n]);
|
||||||
|
}
|
||||||
|
Ok(format!("{:x}", hasher.finalize()))
|
||||||
|
}
|
||||||
|
ChecksumKind::Sha1 => {
|
||||||
|
let mut hasher = Sha1::new();
|
||||||
|
loop {
|
||||||
|
let n = file.read(&mut buf)?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buf[..n]);
|
||||||
|
}
|
||||||
|
Ok(format!("{:x}", hasher.finalize()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry parsed out of an existing checksum file: the file name it references
|
||||||
|
/// (not yet resolved to a real path - checksum files reference files by name only,
|
||||||
|
/// always relative to wherever the checksum file itself lives) and the checksum text
|
||||||
|
/// as written in the file, normalized to the kind's own casing convention.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ChecksumEntry {
|
||||||
|
pub file_name: String,
|
||||||
|
pub checksum: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the text content of a checksum file into its entries, dispatching on format
|
||||||
|
/// by `kind` (`.sfv`'s `name checksum` layout vs. the coreutils `checksum name` one).
|
||||||
|
pub fn parse_checksum_file(content: &str, kind: ChecksumKind) -> Vec<ChecksumEntry> {
|
||||||
|
match kind {
|
||||||
|
ChecksumKind::Crc32 => parse_sfv(content),
|
||||||
|
ChecksumKind::Md5 | ChecksumKind::Sha1 => parse_hashsum(content),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `.sfv` format: one entry per line, `<filename><whitespace><8-hex-digit CRC32>`.
|
||||||
|
/// Blank lines and comment lines (leading `;`, the format's own convention) are
|
||||||
|
/// skipped. The filename is whatever comes before the LAST run of whitespace on the
|
||||||
|
/// line, so a filename containing spaces still parses correctly.
|
||||||
|
fn parse_sfv(content: &str) -> Vec<ChecksumEntry> {
|
||||||
|
content
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|line| !line.is_empty() && !line.starts_with(';'))
|
||||||
|
.filter_map(|line| {
|
||||||
|
let (name, checksum) = line.rsplit_once(char::is_whitespace)?;
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(ChecksumEntry {
|
||||||
|
file_name: name.to_string(),
|
||||||
|
checksum: checksum.trim().to_uppercase(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `.md5`/`.sha1` format (GNU coreutils `md5sum`/`sha1sum` convention): one entry per
|
||||||
|
/// line, `<hex checksum><two spaces, or one space + '*' for binary mode><filename>`.
|
||||||
|
fn parse_hashsum(content: &str) -> Vec<ChecksumEntry> {
|
||||||
|
content
|
||||||
|
.lines()
|
||||||
|
.map(str::trim_end)
|
||||||
|
.filter(|line| !line.is_empty())
|
||||||
|
.filter_map(|line| {
|
||||||
|
let mut parts = line.splitn(2, char::is_whitespace);
|
||||||
|
let checksum = parts.next()?.trim();
|
||||||
|
let rest = parts.next()?.trim_start();
|
||||||
|
let name = rest.strip_prefix('*').unwrap_or(rest);
|
||||||
|
if checksum.is_empty() || name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(ChecksumEntry {
|
||||||
|
file_name: name.to_string(),
|
||||||
|
checksum: checksum.to_lowercase(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the text content of a checksum file from a set of entries, in the given
|
||||||
|
/// kind's own format/casing convention - the inverse of `parse_checksum_file`.
|
||||||
|
pub fn build_checksum_file(entries: &[ChecksumEntry], kind: ChecksumKind) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
match kind {
|
||||||
|
ChecksumKind::Crc32 => {
|
||||||
|
out.push_str("; Generated by Aurora Curator\n");
|
||||||
|
for entry in entries {
|
||||||
|
out.push_str(&entry.file_name);
|
||||||
|
out.push(' ');
|
||||||
|
out.push_str(&entry.checksum.to_uppercase());
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ChecksumKind::Md5 | ChecksumKind::Sha1 => {
|
||||||
|
for entry in entries {
|
||||||
|
out.push_str(&entry.checksum.to_lowercase());
|
||||||
|
out.push_str(" ");
|
||||||
|
out.push_str(&entry.file_name);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome of checking one checksum-file entry against the actual file on disk.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum VerifyStatus {
|
||||||
|
Ok,
|
||||||
|
Mismatch,
|
||||||
|
/// The checksum file lists this name, but no such file exists at the resolved path.
|
||||||
|
Missing,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct VerifyResult {
|
||||||
|
pub file_name: String,
|
||||||
|
pub expected: String,
|
||||||
|
/// `None` only for `Missing` - there was nothing to hash.
|
||||||
|
pub actual: Option<String>,
|
||||||
|
pub status: VerifyStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies each parsed entry against the real file on disk, resolved as
|
||||||
|
/// `base_dir.join(&entry.file_name)` - checksum files almost always reference files by
|
||||||
|
/// bare name, sitting alongside them, so `base_dir` is normally the checksum file's own
|
||||||
|
/// parent directory.
|
||||||
|
pub fn verify_entries(entries: &[ChecksumEntry], base_dir: &Path, kind: ChecksumKind) -> Vec<VerifyResult> {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| {
|
||||||
|
let path = base_dir.join(&entry.file_name);
|
||||||
|
match compute_checksum(&path, kind) {
|
||||||
|
Ok(actual) => {
|
||||||
|
let status = if actual.eq_ignore_ascii_case(&entry.checksum) {
|
||||||
|
VerifyStatus::Ok
|
||||||
|
} else {
|
||||||
|
VerifyStatus::Mismatch
|
||||||
|
};
|
||||||
|
VerifyResult {
|
||||||
|
file_name: entry.file_name.clone(),
|
||||||
|
expected: entry.checksum.clone(),
|
||||||
|
actual: Some(actual),
|
||||||
|
status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => VerifyResult {
|
||||||
|
file_name: entry.file_name.clone(),
|
||||||
|
expected: entry.checksum.clone(),
|
||||||
|
actual: None,
|
||||||
|
status: VerifyStatus::Missing,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
|
||||||
|
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||||
|
|
||||||
|
fn temp_dir() -> std::path::PathBuf {
|
||||||
|
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let dir = std::env::temp_dir().join(format!("aurora_curator_sfv_test_{}_{}", std::process::id(), n));
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crc32_matches_the_well_known_check_value() {
|
||||||
|
// "123456789" is the standard CRC32 (IEEE) test vector - checksum CBF43926.
|
||||||
|
let dir = temp_dir();
|
||||||
|
let path = dir.join("check.txt");
|
||||||
|
fs::write(&path, b"123456789").unwrap();
|
||||||
|
assert_eq!(compute_checksum(&path, ChecksumKind::Crc32).unwrap(), "CBF43926");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn md5_matches_a_known_hash() {
|
||||||
|
// md5("") = d41d8cd98f00b204e9800998ecf8427e (the empty-string test vector).
|
||||||
|
let dir = temp_dir();
|
||||||
|
let path = dir.join("empty.txt");
|
||||||
|
fs::write(&path, b"").unwrap();
|
||||||
|
assert_eq!(compute_checksum(&path, ChecksumKind::Md5).unwrap(), "d41d8cd98f00b204e9800998ecf8427e");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sha1_matches_a_known_hash() {
|
||||||
|
// sha1("abc") = a9993e364706816aba3e25717850c26c9cd0d89d (the classic FIPS 180 test vector).
|
||||||
|
let dir = temp_dir();
|
||||||
|
let path = dir.join("abc.txt");
|
||||||
|
fs::write(&path, b"abc").unwrap();
|
||||||
|
assert_eq!(compute_checksum(&path, ChecksumKind::Sha1).unwrap(), "a9993e364706816aba3e25717850c26c9cd0d89d");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compute_checksum_errors_when_the_file_does_not_exist() {
|
||||||
|
let dir = temp_dir();
|
||||||
|
let missing = dir.join("nope.txt");
|
||||||
|
assert!(compute_checksum(&missing, ChecksumKind::Crc32).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_a_typical_sfv_file_skipping_comments_and_blank_lines() {
|
||||||
|
let content = "; Generated by Aurora Curator\n\nShow.S01E01.mkv CBF43926\nShow.S01E02.mkv 12345678\n";
|
||||||
|
let entries = parse_checksum_file(content, ChecksumKind::Crc32);
|
||||||
|
assert_eq!(
|
||||||
|
entries,
|
||||||
|
vec![
|
||||||
|
ChecksumEntry { file_name: "Show.S01E01.mkv".to_string(), checksum: "CBF43926".to_string() },
|
||||||
|
ChecksumEntry { file_name: "Show.S01E02.mkv".to_string(), checksum: "12345678".to_string() },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sfv_filenames_with_spaces_still_parse_correctly() {
|
||||||
|
let content = "My Show S01E01.mkv CBF43926\n";
|
||||||
|
let entries = parse_checksum_file(content, ChecksumKind::Crc32);
|
||||||
|
assert_eq!(entries, vec![ChecksumEntry { file_name: "My Show S01E01.mkv".to_string(), checksum: "CBF43926".to_string() }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_a_typical_md5sum_style_file_including_binary_mode_marker() {
|
||||||
|
let content = "d41d8cd98f00b204e9800998ecf8427e empty.txt\na9993e364706816aba3e25717850c26c9cd0d89d *abc.txt\n";
|
||||||
|
let entries = parse_checksum_file(content, ChecksumKind::Sha1);
|
||||||
|
assert_eq!(
|
||||||
|
entries,
|
||||||
|
vec![
|
||||||
|
ChecksumEntry { file_name: "empty.txt".to_string(), checksum: "d41d8cd98f00b204e9800998ecf8427e".to_string() },
|
||||||
|
ChecksumEntry { file_name: "abc.txt".to_string(), checksum: "a9993e364706816aba3e25717850c26c9cd0d89d".to_string() },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_checksum_file_round_trips_through_parse_for_sfv() {
|
||||||
|
let entries = vec![
|
||||||
|
ChecksumEntry { file_name: "a.mkv".to_string(), checksum: "cbf43926".to_string() },
|
||||||
|
ChecksumEntry { file_name: "b.mkv".to_string(), checksum: "deadbeef".to_string() },
|
||||||
|
];
|
||||||
|
let text = build_checksum_file(&entries, ChecksumKind::Crc32);
|
||||||
|
let parsed = parse_checksum_file(&text, ChecksumKind::Crc32);
|
||||||
|
assert_eq!(
|
||||||
|
parsed,
|
||||||
|
vec![
|
||||||
|
ChecksumEntry { file_name: "a.mkv".to_string(), checksum: "CBF43926".to_string() },
|
||||||
|
ChecksumEntry { file_name: "b.mkv".to_string(), checksum: "DEADBEEF".to_string() },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_checksum_file_round_trips_through_parse_for_md5() {
|
||||||
|
let entries = vec![ChecksumEntry { file_name: "a.mkv".to_string(), checksum: "D41D8CD98F00B204E9800998ECF8427E".to_string() }];
|
||||||
|
let text = build_checksum_file(&entries, ChecksumKind::Md5);
|
||||||
|
let parsed = parse_checksum_file(&text, ChecksumKind::Md5);
|
||||||
|
assert_eq!(parsed, vec![ChecksumEntry { file_name: "a.mkv".to_string(), checksum: "d41d8cd98f00b204e9800998ecf8427e".to_string() }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_entries_reports_ok_for_a_matching_file() {
|
||||||
|
let dir = temp_dir();
|
||||||
|
fs::write(dir.join("a.txt"), b"abc").unwrap();
|
||||||
|
let entries = vec![ChecksumEntry {
|
||||||
|
file_name: "a.txt".to_string(),
|
||||||
|
checksum: "a9993e364706816aba3e25717850c26c9cd0d89d".to_string(),
|
||||||
|
}];
|
||||||
|
let results = verify_entries(&entries, &dir, ChecksumKind::Sha1);
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].status, VerifyStatus::Ok);
|
||||||
|
assert_eq!(results[0].actual.as_deref(), Some("a9993e364706816aba3e25717850c26c9cd0d89d"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_entries_reports_mismatch_for_a_changed_file() {
|
||||||
|
let dir = temp_dir();
|
||||||
|
fs::write(dir.join("a.txt"), b"changed content").unwrap();
|
||||||
|
let entries = vec![ChecksumEntry {
|
||||||
|
file_name: "a.txt".to_string(),
|
||||||
|
checksum: "a9993e364706816aba3e25717850c26c9cd0d89d".to_string(),
|
||||||
|
}];
|
||||||
|
let results = verify_entries(&entries, &dir, ChecksumKind::Sha1);
|
||||||
|
assert_eq!(results[0].status, VerifyStatus::Mismatch);
|
||||||
|
assert!(results[0].actual.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_entries_reports_missing_for_a_file_that_does_not_exist() {
|
||||||
|
let dir = temp_dir();
|
||||||
|
let entries = vec![ChecksumEntry { file_name: "nope.txt".to_string(), checksum: "CBF43926".to_string() }];
|
||||||
|
let results = verify_entries(&entries, &dir, ChecksumKind::Crc32);
|
||||||
|
assert_eq!(results[0].status, VerifyStatus::Missing);
|
||||||
|
assert_eq!(results[0].actual, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_is_case_insensitive_on_the_checksum_text() {
|
||||||
|
let dir = temp_dir();
|
||||||
|
fs::write(dir.join("a.txt"), b"123456789").unwrap();
|
||||||
|
let entries = vec![ChecksumEntry { file_name: "a.txt".to_string(), checksum: "cbf43926".to_string() }];
|
||||||
|
let results = verify_entries(&entries, &dir, ChecksumKind::Crc32);
|
||||||
|
assert_eq!(results[0].status, VerifyStatus::Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checksum_kind_extension_and_label_match_convention() {
|
||||||
|
assert_eq!(ChecksumKind::Crc32.extension(), "sfv");
|
||||||
|
assert_eq!(ChecksumKind::Md5.extension(), "md5");
|
||||||
|
assert_eq!(ChecksumKind::Sha1.extension(), "sha1");
|
||||||
|
assert_eq!(ChecksumKind::Crc32.label(), "CRC32");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
//! rank first - TMDB ranks by relevance/popularity, not by exact name match, so a
|
//! rank first - TMDB ranks by relevance/popularity, not by exact name match, so a
|
||||||
//! more popular but differently-named show can otherwise beat an exact-but-less-popular
|
//! more popular but differently-named show can otherwise beat an exact-but-less-popular
|
||||||
//! one straight to the top of the list. `DateMatcher` (the other half of the original
|
//! one straight to the top of the list. `DateMatcher` (the other half of the original
|
||||||
//! `EpisodeMetrics`) is not ported - Renamr doesn't do date-based episode identification.
|
//! `EpisodeMetrics`) is not ported - Aurora Curator doesn't do date-based episode identification.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//! Online metadata clients ("Fetch Data") plus, as of `opensubtitles`, a first
|
||||||
|
//! non-metadata web client. TMDB (TV + movies), TVmaze (TV only, no API key needed),
|
||||||
|
//! and OMDb (TV + movies, needs a free API key) handle title/air-date lookups; the
|
||||||
|
//! original FileBot supports ~40 sources (`net.filebot.web.*`) - TMDB was picked first
|
||||||
|
//! since it's the most widely useful for both TV and movies, TVmaze second since it's a
|
||||||
|
//! zero-setup second TV source (see `tvmaze`'s module doc for why movies aren't in
|
||||||
|
//! scope for it), OMDb third since it's the first *movie*-capable alternative to TMDB
|
||||||
|
//! (see `omdb`'s module doc). `opensubtitles` is a different category entirely -
|
||||||
|
//! fetching actual subtitle files rather than title metadata - see its own module doc.
|
||||||
|
|
||||||
|
pub mod omdb;
|
||||||
|
pub mod opensubtitles;
|
||||||
|
pub mod tmdb;
|
||||||
|
pub mod tvmaze;
|
||||||
@@ -0,0 +1,625 @@
|
|||||||
|
//! A client for [OMDb](https://www.omdbapi.com/)'s REST API: search for a TV show or
|
||||||
|
//! movie by name, then look up real metadata (a specific episode's title/air date, or a
|
||||||
|
//! movie's canonical title/release date).
|
||||||
|
//!
|
||||||
|
//! A third metadata source alongside TMDB and TVmaze - and, unlike TVmaze, one that
|
||||||
|
//! covers **both** movies and TV, same as TMDB, so it slots into both of Fetch Data's
|
||||||
|
//! source pickers rather than just the TV one. Needs a free OMDb API key
|
||||||
|
//! (<https://www.omdbapi.com/apikey.aspx>), sent as a plain `apikey` query parameter on
|
||||||
|
//! every request - OMDb has no Bearer-token scheme like TMDB, just the one flat query
|
||||||
|
//! param, and (unlike TMDB's REST-path-per-resource shape) every OMDb request hits the
|
||||||
|
//! same root URL with different query parameters (`s=` search, `i=`/`t=` detail lookup,
|
||||||
|
//! `i=`+`Season=` episode listing).
|
||||||
|
//!
|
||||||
|
//! OMDb's own date fields (`Released`, both on movie details and on episodes) come back
|
||||||
|
//! as human text like `"16 Jul 2010"`, or the literal string `"N/A"` when unknown - see
|
||||||
|
//! [`parse_release_date`], which normalizes that into the same `YYYY-MM-DD` shape
|
||||||
|
//! TMDB/TVmaze already use, so the Tauri glue and the naming-scheme `{airdate}` binding
|
||||||
|
//! don't need to know which source produced a result.
|
||||||
|
//!
|
||||||
|
//! Known limitation, deliberately not engineered around: OMDb returns HTTP 200 (not an
|
||||||
|
//! error status) for both "no results" and "invalid API key" - `{"Response":"False",
|
||||||
|
//! "Error":"..."}` either way - so this client can't reliably distinguish "nothing
|
||||||
|
//! matched" from "your key is wrong" the way TMDB's real 401 lets `TmdbError::Api`
|
||||||
|
//! surface a bad token distinctly. Both cases fall through to [`OmdbError::NotFound`]
|
||||||
|
//! here, same as an empty TMDB/TVmaze result set; a user with a broken key just sees "no
|
||||||
|
//! match found" repeatedly rather than a specific "invalid key" message.
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::similarity::best_title_match;
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL: &str = "https://www.omdbapi.com";
|
||||||
|
|
||||||
|
/// Same threshold TMDB/TVmaze use (see `tmdb::NAME_MATCH_THRESHOLD`) - kept identical
|
||||||
|
/// so switching sources doesn't change how forgiving/strict name matching feels.
|
||||||
|
const NAME_MATCH_THRESHOLD: f64 = 0.35;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum OmdbError {
|
||||||
|
#[error("network error talking to OMDb: {0}")]
|
||||||
|
Network(String),
|
||||||
|
#[error("OMDb API error (status {status}): {message}")]
|
||||||
|
Api { status: u16, message: String },
|
||||||
|
#[error("no OMDb match found for \"{0}\"")]
|
||||||
|
NotFound(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts OMDb's `"16 Jul 2010"`-style date text into `YYYY-MM-DD` (matching what
|
||||||
|
/// TMDB/TVmaze already return), or `None` for `"N/A"`/empty/anything unparseable.
|
||||||
|
fn parse_release_date(raw: &str) -> Option<String> {
|
||||||
|
if raw.trim().is_empty() || raw.eq_ignore_ascii_case("n/a") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let parts: Vec<&str> = raw.split_whitespace().collect();
|
||||||
|
let [day, month, year] = parts[..] else { return None };
|
||||||
|
let day: u32 = day.parse().ok()?;
|
||||||
|
let month = month_number(month)?;
|
||||||
|
let year: u32 = year.parse().ok()?;
|
||||||
|
Some(format!("{year:04}-{month:02}-{day:02}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn month_number(name: &str) -> Option<u32> {
|
||||||
|
Some(match name.to_ascii_lowercase().as_str() {
|
||||||
|
"jan" => 1,
|
||||||
|
"feb" => 2,
|
||||||
|
"mar" => 3,
|
||||||
|
"apr" => 4,
|
||||||
|
"may" => 5,
|
||||||
|
"jun" => 6,
|
||||||
|
"jul" => 7,
|
||||||
|
"aug" => 8,
|
||||||
|
"sep" => 9,
|
||||||
|
"oct" => 10,
|
||||||
|
"nov" => 11,
|
||||||
|
"dec" => 12,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct SearchResponse {
|
||||||
|
#[serde(default, rename = "Search")]
|
||||||
|
search: Vec<SearchHit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct SearchHit {
|
||||||
|
#[serde(rename = "Title")]
|
||||||
|
title: String,
|
||||||
|
#[serde(rename = "imdbID")]
|
||||||
|
imdb_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One OMDb TV-show search hit. Only the fields Aurora Curator actually uses are kept.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TvResult {
|
||||||
|
pub imdb_id: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One OMDb movie search hit.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MovieResult {
|
||||||
|
pub imdb_id: String,
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct SeasonResponse {
|
||||||
|
#[serde(default, rename = "Episodes")]
|
||||||
|
episodes: Vec<EpisodeHit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct EpisodeHit {
|
||||||
|
#[serde(rename = "Title")]
|
||||||
|
title: String,
|
||||||
|
/// A numeric string (`"1"`), but occasionally `"N/A"` for an uncategorized special
|
||||||
|
/// - parsed defensively in `season_episodes`, same "skip rather than panic"
|
||||||
|
/// convention TVmaze's null-`number` episodes use.
|
||||||
|
#[serde(rename = "Episode")]
|
||||||
|
episode: String,
|
||||||
|
#[serde(rename = "Released", default)]
|
||||||
|
released: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct DetailResponse {
|
||||||
|
#[serde(rename = "Released", default)]
|
||||||
|
released: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real metadata for one episode, resolved from OMDb - same shape as
|
||||||
|
/// `tmdb::EpisodeInfo`/`tvmaze::EpisodeInfo` so the Tauri glue can treat any source's
|
||||||
|
/// result identically.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct EpisodeInfo {
|
||||||
|
pub series_name: String,
|
||||||
|
pub episode_title: String,
|
||||||
|
pub air_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One episode of a season, as returned by `season_episodes()` - mirrors
|
||||||
|
/// `tmdb::SeasonEpisode`/`tvmaze::SeasonEpisode`'s field names.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SeasonEpisode {
|
||||||
|
pub episode_number: i32,
|
||||||
|
pub title: String,
|
||||||
|
pub air_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real metadata for one movie, resolved from OMDb - mirrors `tmdb::MovieInfo`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct MovieInfo {
|
||||||
|
pub title: String,
|
||||||
|
pub release_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct OmdbClient {
|
||||||
|
http: reqwest::Client,
|
||||||
|
api_key: String,
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OmdbClient {
|
||||||
|
pub fn new(api_key: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
http: reqwest::Client::new(),
|
||||||
|
api_key: api_key.into(),
|
||||||
|
base_url: DEFAULT_BASE_URL.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overrides the API base URL - used by tests to point at a local mock server
|
||||||
|
/// instead of the real OMDb API.
|
||||||
|
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||||
|
self.base_url = base_url.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every OMDb request hits the same root URL with different query parameters (no
|
||||||
|
/// per-resource paths like TMDB's `/search/movie`) - `apikey` is appended here so
|
||||||
|
/// every call site gets it for free rather than repeating it.
|
||||||
|
async fn get_json<T: for<'de> Deserialize<'de>>(&self, query: &[(&str, &str)]) -> Result<T, OmdbError> {
|
||||||
|
let mut full_query: Vec<(&str, &str)> = query.to_vec();
|
||||||
|
full_query.push(("apikey", self.api_key.as_str()));
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.get(&self.base_url)
|
||||||
|
.query(&full_query)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| OmdbError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let message = response.text().await.unwrap_or_default();
|
||||||
|
return Err(OmdbError::Api { status, message });
|
||||||
|
}
|
||||||
|
|
||||||
|
response
|
||||||
|
.json::<T>()
|
||||||
|
.await
|
||||||
|
.map_err(|e| OmdbError::Network(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Searches for a TV show by name. Results come back OMDb-ranked; callers wanting
|
||||||
|
/// "the best guess" should use `search_tv_best_match` instead, same convention as
|
||||||
|
/// the TMDB/TVmaze clients.
|
||||||
|
pub async fn search_tv(&self, query: &str) -> Result<Vec<TvResult>, OmdbError> {
|
||||||
|
let parsed: SearchResponse = self.get_json(&[("s", query), ("type", "series")]).await?;
|
||||||
|
Ok(parsed
|
||||||
|
.search
|
||||||
|
.into_iter()
|
||||||
|
.map(|h| TvResult { imdb_id: h.imdb_id, name: h.title })
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Searches for a TV show by name and returns the single best-matching result (see
|
||||||
|
/// `similarity::best_title_match`), or `None` if nothing is a plausible match at
|
||||||
|
/// all. Mirrors `TmdbClient::search_tv_best_match`/`TvmazeClient::search_tv_best_match`.
|
||||||
|
pub async fn search_tv_best_match(&self, query: &str) -> Result<Option<TvResult>, OmdbError> {
|
||||||
|
let results = self.search_tv(query).await?;
|
||||||
|
Ok(best_title_match(query, &results, |r| r.name.as_str(), NAME_MATCH_THRESHOLD).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches one season's episodes for a known OMDb (IMDb) series id.
|
||||||
|
pub async fn season_episodes(&self, series_imdb_id: &str, season: i32) -> Result<Vec<SeasonEpisode>, OmdbError> {
|
||||||
|
let season_str = season.to_string();
|
||||||
|
let parsed: SeasonResponse = self.get_json(&[("i", series_imdb_id), ("Season", &season_str)]).await?;
|
||||||
|
|
||||||
|
Ok(parsed
|
||||||
|
.episodes
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| {
|
||||||
|
let episode_number = e.episode.parse::<i32>().ok()?;
|
||||||
|
Some(SeasonEpisode {
|
||||||
|
episode_number,
|
||||||
|
title: e.title,
|
||||||
|
air_date: parse_release_date(&e.released),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: search for the best-matching show by name, then fetch a specific
|
||||||
|
/// episode. Same disambiguation approach as the TMDB/TVmaze clients - picks
|
||||||
|
/// whichever result's name is textually closest to `series_query`, and reports
|
||||||
|
/// `NotFound` instead of guessing when nothing is a plausible match.
|
||||||
|
pub async fn lookup_episode(
|
||||||
|
&self,
|
||||||
|
series_query: &str,
|
||||||
|
season: i32,
|
||||||
|
episode: i32,
|
||||||
|
) -> Result<EpisodeInfo, OmdbError> {
|
||||||
|
let best = self
|
||||||
|
.search_tv_best_match(series_query)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| OmdbError::NotFound(series_query.to_string()))?;
|
||||||
|
|
||||||
|
let episodes = self.season_episodes(&best.imdb_id, season).await?;
|
||||||
|
let found = episodes
|
||||||
|
.into_iter()
|
||||||
|
.find(|e| e.episode_number == episode)
|
||||||
|
.ok_or_else(|| OmdbError::NotFound(format!("S{season:02}E{episode:02}")))?;
|
||||||
|
|
||||||
|
Ok(EpisodeInfo {
|
||||||
|
series_name: best.name,
|
||||||
|
episode_title: found.title,
|
||||||
|
air_date: found.air_date,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Searches for a movie by title. OMDb's search endpoint doesn't take a release-year
|
||||||
|
/// filter the way TMDB's does, so `year` is accepted for signature parity with
|
||||||
|
/// `TmdbClient::search_movie` (name-similarity disambiguation alone is what actually
|
||||||
|
/// picks the best result) - OMDb does support a `y=` search parameter, so it's used
|
||||||
|
/// here to narrow the search server-side when a year is known, same intent as TMDB's
|
||||||
|
/// `primary_release_year`.
|
||||||
|
pub async fn search_movie(&self, query: &str, year: Option<i32>) -> Result<Vec<MovieResult>, OmdbError> {
|
||||||
|
let year_str;
|
||||||
|
let mut params: Vec<(&str, &str)> = vec![("s", query), ("type", "movie")];
|
||||||
|
if let Some(y) = year {
|
||||||
|
year_str = y.to_string();
|
||||||
|
params.push(("y", &year_str));
|
||||||
|
}
|
||||||
|
let parsed: SearchResponse = self.get_json(¶ms).await?;
|
||||||
|
Ok(parsed
|
||||||
|
.search
|
||||||
|
.into_iter()
|
||||||
|
.map(|h| MovieResult { imdb_id: h.imdb_id, title: h.title })
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: search for the best-matching movie by title (and optional release
|
||||||
|
/// year), then fetch its full detail record for a real release date - OMDb's search
|
||||||
|
/// results only carry a bare `Year`, not a full date, unlike TMDB's search results.
|
||||||
|
pub async fn lookup_movie(&self, query: &str, year: Option<i32>) -> Result<MovieInfo, OmdbError> {
|
||||||
|
let results = self.search_movie(query, year).await?;
|
||||||
|
let best = best_title_match(query, &results, |r| r.title.as_str(), NAME_MATCH_THRESHOLD)
|
||||||
|
.ok_or_else(|| OmdbError::NotFound(query.to_string()))?;
|
||||||
|
|
||||||
|
let detail: DetailResponse = self.get_json(&[("i", best.imdb_id.as_str())]).await?;
|
||||||
|
Ok(MovieInfo {
|
||||||
|
title: best.title.clone(),
|
||||||
|
release_date: parse_release_date(&detail.released),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_omdb_style_dates_into_iso_form() {
|
||||||
|
assert_eq!(parse_release_date("16 Jul 2010"), Some("2010-07-16".to_string()));
|
||||||
|
assert_eq!(parse_release_date("01 Jan 2008"), Some("2008-01-01".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn treats_na_and_empty_dates_as_unknown() {
|
||||||
|
assert_eq!(parse_release_date("N/A"), None);
|
||||||
|
assert_eq!(parse_release_date("n/a"), None);
|
||||||
|
assert_eq!(parse_release_date(""), None);
|
||||||
|
assert_eq!(parse_release_date("not a date"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn looks_up_a_real_episode_end_to_end() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _search_mock = server
|
||||||
|
.mock("GET", "/")
|
||||||
|
.match_query(mockito::Matcher::AllOf(vec![
|
||||||
|
mockito::Matcher::UrlEncoded("s".into(), "Girls".into()),
|
||||||
|
mockito::Matcher::UrlEncoded("type".into(), "series".into()),
|
||||||
|
]))
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"{"Search":[{"Title":"Girls","imdbID":"tt1723816"}],"Response":"True"}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let _season_mock = server
|
||||||
|
.mock("GET", "/")
|
||||||
|
.match_query(mockito::Matcher::AllOf(vec![
|
||||||
|
mockito::Matcher::UrlEncoded("i".into(), "tt1723816".into()),
|
||||||
|
mockito::Matcher::UrlEncoded("Season".into(), "2".into()),
|
||||||
|
]))
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(
|
||||||
|
r#"{"Episodes":[
|
||||||
|
{"Title":"It's About Time","Episode":"1","Released":"13 Jan 2013"},
|
||||||
|
{"Title":"I Get Ideas","Episode":"2","Released":"20 Jan 2013"}
|
||||||
|
],"Response":"True"}"#,
|
||||||
|
)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = OmdbClient::new("test-key").with_base_url(server.url());
|
||||||
|
let info = client.lookup_episode("Girls", 2, 2).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
info,
|
||||||
|
EpisodeInfo {
|
||||||
|
series_name: "Girls".to_string(),
|
||||||
|
episode_title: "I Get Ideas".to_string(),
|
||||||
|
air_date: Some("2013-01-20".to_string()),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reports_not_found_when_search_has_no_results() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _mock = server
|
||||||
|
.mock("GET", "/")
|
||||||
|
.match_query(mockito::Matcher::Any)
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"{"Response":"False","Error":"Series not found!"}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = OmdbClient::new("test-key").with_base_url(server.url());
|
||||||
|
let result = client.lookup_episode("Some Nonexistent Show", 1, 1).await;
|
||||||
|
|
||||||
|
assert!(matches!(result, Err(OmdbError::NotFound(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reports_not_found_when_the_season_has_no_such_episode() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _search_mock = server
|
||||||
|
.mock("GET", "/")
|
||||||
|
.match_query(mockito::Matcher::UrlEncoded("s".into(), "Some Show".into()))
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"{"Search":[{"Title":"Some Show","imdbID":"tt0000042"}],"Response":"True"}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let _season_mock = server
|
||||||
|
.mock("GET", "/")
|
||||||
|
.match_query(mockito::Matcher::UrlEncoded("i".into(), "tt0000042".into()))
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"{"Episodes":[{"Title":"Pilot","Episode":"1","Released":"01 Jan 2020"}],"Response":"True"}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = OmdbClient::new("test-key").with_base_url(server.url());
|
||||||
|
// Season 1 only has episode 1 in this mock - asking for episode 99 should miss.
|
||||||
|
let result = client.lookup_episode("Some Show", 1, 99).await;
|
||||||
|
|
||||||
|
assert!(matches!(result, Err(OmdbError::NotFound(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn picks_the_exact_name_match_over_a_more_popular_but_wrong_result() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _search_mock = server
|
||||||
|
.mock("GET", "/")
|
||||||
|
.match_query(mockito::Matcher::UrlEncoded("s".into(), "Chuck".into()))
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(
|
||||||
|
r#"{"Search":[
|
||||||
|
{"Title":"Chucky","imdbID":"tt1"},
|
||||||
|
{"Title":"Chuck","imdbID":"tt2"}
|
||||||
|
],"Response":"True"}"#,
|
||||||
|
)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let _season_mock = server
|
||||||
|
.mock("GET", "/")
|
||||||
|
.match_query(mockito::Matcher::UrlEncoded("i".into(), "tt2".into()))
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(
|
||||||
|
r#"{"Episodes":[{"Title":"Chuck Versus the Intersect","Episode":"1","Released":"24 Sep 2007"}],"Response":"True"}"#,
|
||||||
|
)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = OmdbClient::new("test-key").with_base_url(server.url());
|
||||||
|
let info = client.lookup_episode("Chuck", 1, 1).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(info.series_name, "Chuck");
|
||||||
|
assert_eq!(info.episode_title, "Chuck Versus the Intersect");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reports_not_found_when_no_search_result_is_a_plausible_name_match() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _mock = server
|
||||||
|
.mock("GET", "/")
|
||||||
|
.match_query(mockito::Matcher::Any)
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"{"Search":[{"Title":"Totally Unrelated Program","imdbID":"tt9"}],"Response":"True"}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = OmdbClient::new("test-key").with_base_url(server.url());
|
||||||
|
let result = client.lookup_episode("Zzyzx Nonexistent Show", 1, 1).await;
|
||||||
|
|
||||||
|
assert!(matches!(result, Err(OmdbError::NotFound(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn episodes_with_an_unparseable_episode_number_are_skipped_rather_than_panicking() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _search_mock = server
|
||||||
|
.mock("GET", "/")
|
||||||
|
.match_query(mockito::Matcher::UrlEncoded("s".into(), "Some Show".into()))
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"{"Search":[{"Title":"Some Show","imdbID":"tt7"}],"Response":"True"}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let _season_mock = server
|
||||||
|
.mock("GET", "/")
|
||||||
|
.match_query(mockito::Matcher::UrlEncoded("i".into(), "tt7".into()))
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(
|
||||||
|
r#"{"Episodes":[
|
||||||
|
{"Title":"Uncategorized Special","Episode":"N/A","Released":"N/A"},
|
||||||
|
{"Title":"Pilot","Episode":"1","Released":"01 Jan 2021"}
|
||||||
|
],"Response":"True"}"#,
|
||||||
|
)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = OmdbClient::new("test-key").with_base_url(server.url());
|
||||||
|
let info = client.lookup_episode("Some Show", 1, 1).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(info.episode_title, "Pilot");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn looks_up_a_real_movie_end_to_end() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _search_mock = server
|
||||||
|
.mock("GET", "/")
|
||||||
|
.match_query(mockito::Matcher::AllOf(vec![
|
||||||
|
mockito::Matcher::UrlEncoded("s".into(), "Inception".into()),
|
||||||
|
mockito::Matcher::UrlEncoded("type".into(), "movie".into()),
|
||||||
|
mockito::Matcher::UrlEncoded("y".into(), "2010".into()),
|
||||||
|
]))
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"{"Search":[{"Title":"Inception","imdbID":"tt1375666"}],"Response":"True"}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let _detail_mock = server
|
||||||
|
.mock("GET", "/")
|
||||||
|
.match_query(mockito::Matcher::UrlEncoded("i".into(), "tt1375666".into()))
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"{"Title":"Inception","Released":"16 Jul 2010","Response":"True"}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = OmdbClient::new("test-key").with_base_url(server.url());
|
||||||
|
let info = client.lookup_movie("Inception", Some(2010)).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
info,
|
||||||
|
MovieInfo {
|
||||||
|
title: "Inception".to_string(),
|
||||||
|
release_date: Some("2010-07-16".to_string()),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn picks_the_exact_movie_title_match_over_a_more_popular_but_wrong_result() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _search_mock = server
|
||||||
|
.mock("GET", "/")
|
||||||
|
.match_query(mockito::Matcher::UrlEncoded("s".into(), "Up".into()))
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(
|
||||||
|
r#"{"Search":[
|
||||||
|
{"Title":"Wake Up","imdbID":"tt1"},
|
||||||
|
{"Title":"Up","imdbID":"tt2"}
|
||||||
|
],"Response":"True"}"#,
|
||||||
|
)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let _detail_mock = server
|
||||||
|
.mock("GET", "/")
|
||||||
|
.match_query(mockito::Matcher::UrlEncoded("i".into(), "tt2".into()))
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"{"Title":"Up","Released":"29 May 2009","Response":"True"}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = OmdbClient::new("test-key").with_base_url(server.url());
|
||||||
|
let info = client.lookup_movie("Up", None).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(info.title, "Up");
|
||||||
|
assert_eq!(info.release_date, Some("2009-05-29".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reports_not_found_when_movie_search_has_no_results() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _mock = server
|
||||||
|
.mock("GET", "/")
|
||||||
|
.match_query(mockito::Matcher::Any)
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"{"Response":"False","Error":"Movie not found!"}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = OmdbClient::new("test-key").with_base_url(server.url());
|
||||||
|
let result = client.lookup_movie("Some Nonexistent Movie", None).await;
|
||||||
|
|
||||||
|
assert!(matches!(result, Err(OmdbError::NotFound(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn surfaces_a_server_error_as_an_api_error() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _mock = server
|
||||||
|
.mock("GET", "/")
|
||||||
|
.match_query(mockito::Matcher::Any)
|
||||||
|
.with_status(503)
|
||||||
|
.with_header("content-type", "text/plain")
|
||||||
|
.with_body("service unavailable")
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = OmdbClient::new("test-key").with_base_url(server.url());
|
||||||
|
let result = client.search_tv("anything").await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Err(OmdbError::Api { status, .. }) => assert_eq!(status, 503),
|
||||||
|
other => panic!("expected Api error, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
//! A client for [OpenSubtitles](https://www.opensubtitles.com)' REST API
|
||||||
|
//! (`api.opensubtitles.com/api/v1`) - search for subtitles matching a TV episode or
|
||||||
|
//! movie, then download the chosen one.
|
||||||
|
//!
|
||||||
|
//! Unlike every other client in this module, OpenSubtitles needs **two** different
|
||||||
|
//! credentials for two different operations:
|
||||||
|
//! - **Search** only needs an `Api-Key` header (a free "consumer"/app key registered
|
||||||
|
//! at opensubtitles.com under Account -> API Consumers).
|
||||||
|
//! - **Download** additionally needs the end user's own OpenSubtitles account to be
|
||||||
|
//! logged in (`POST /login` with `username`/`password`, returning a short-lived JWT
|
||||||
|
//! sent as `Authorization: Bearer <token>` on the actual download call) - downloads
|
||||||
|
//! are quota-limited per account (their docs show this ranging from single digits
|
||||||
|
//! to 1000/day depending on account rank/VIP status, and the exact number has
|
||||||
|
//! changed over time), so the download response's `remaining`/`allowed` counts are
|
||||||
|
//! surfaced to the caller rather than assumed.
|
||||||
|
//!
|
||||||
|
//! This is the first source in Aurora Curator that isn't a metadata lookup (title/air
|
||||||
|
//! date) - it fetches actual subtitle *files* - so its result/error shapes are its own,
|
||||||
|
//! not mirrored from `tmdb`/`tvmaze`/`omdb`.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL: &str = "https://api.opensubtitles.com/api/v1";
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum OpensubtitlesError {
|
||||||
|
#[error("network error talking to OpenSubtitles: {0}")]
|
||||||
|
Network(String),
|
||||||
|
#[error("OpenSubtitles API error (status {status}): {message}")]
|
||||||
|
Api { status: u16, message: String },
|
||||||
|
#[error("OpenSubtitles login failed: check the username and password in Fetch Data Settings")]
|
||||||
|
InvalidCredentials,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search parameters. `query` should be a show/movie title; `season`/`episode` narrow a
|
||||||
|
/// TV search to one episode, `year` narrows either kind to one release year. `language`
|
||||||
|
/// is a single OpenSubtitles language code (e.g. `"en"`).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SearchParams {
|
||||||
|
pub query: String,
|
||||||
|
pub season: Option<u32>,
|
||||||
|
pub episode: Option<u32>,
|
||||||
|
pub year: Option<u32>,
|
||||||
|
pub language: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One subtitle search result, flattened to the fields Aurora Curator actually shows -
|
||||||
|
/// OpenSubtitles' real JSON shape wraps all of this in a `{data: [{attributes: {...}}]}`
|
||||||
|
/// envelope with many more fields than are useful here (see `SearchAttributes` below).
|
||||||
|
/// `camelCase` on the wire (same convention `matching::EpisodeGuess`/`MovieGuess` use)
|
||||||
|
/// since this is returned directly by the `search_subtitles` Tauri command with no
|
||||||
|
/// separate presentation-layer wrapper struct.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SubtitleResult {
|
||||||
|
/// The id needed to actually download this subtitle - see `OpensubtitlesClient::download`.
|
||||||
|
pub file_id: u64,
|
||||||
|
pub release: String,
|
||||||
|
pub language: String,
|
||||||
|
pub download_count: i64,
|
||||||
|
pub hearing_impaired: bool,
|
||||||
|
pub hd: bool,
|
||||||
|
/// True when this result was matched via an exact file-hash match rather than a
|
||||||
|
/// text search - a much stronger correctness signal than a title match alone.
|
||||||
|
pub moviehash_match: bool,
|
||||||
|
pub uploader: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a successful download call: a temporary link to the actual subtitle file,
|
||||||
|
/// plus the account's live daily quota state so the caller can show it to the user.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct SubtitleDownload {
|
||||||
|
pub link: String,
|
||||||
|
pub file_name: String,
|
||||||
|
pub remaining: i64,
|
||||||
|
pub allowed: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SearchResponse {
|
||||||
|
data: Vec<SearchHit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SearchHit {
|
||||||
|
attributes: SearchAttributes,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SearchAttributes {
|
||||||
|
release: String,
|
||||||
|
#[serde(default)]
|
||||||
|
language: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
download_count: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
hearing_impaired: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
hd: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
moviehash_match: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
uploader: Option<UploaderInfo>,
|
||||||
|
#[serde(default)]
|
||||||
|
files: Vec<SubtitleFileRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct UploaderInfo {
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct SubtitleFileRef {
|
||||||
|
file_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct LoginRequest<'a> {
|
||||||
|
username: &'a str,
|
||||||
|
password: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LoginResponse {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct DownloadRequest {
|
||||||
|
file_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct DownloadResponseBody {
|
||||||
|
link: String,
|
||||||
|
#[serde(default)]
|
||||||
|
file_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
remaining: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
allowed: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct OpensubtitlesClient {
|
||||||
|
http: reqwest::Client,
|
||||||
|
base_url: String,
|
||||||
|
api_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpensubtitlesClient {
|
||||||
|
pub fn new(api_key: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
http: reqwest::Client::new(),
|
||||||
|
base_url: DEFAULT_BASE_URL.to_string(),
|
||||||
|
api_key: api_key.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overrides the API base URL - used by tests to point at a local mock server
|
||||||
|
/// instead of the real OpenSubtitles API.
|
||||||
|
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||||
|
self.base_url = base_url.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Searches for subtitles. Only needs the app-level `Api-Key` - no user login
|
||||||
|
/// required. Results with no downloadable file attached are skipped rather than
|
||||||
|
/// surfaced with a missing id.
|
||||||
|
pub async fn search(&self, params: &SearchParams) -> Result<Vec<SubtitleResult>, OpensubtitlesError> {
|
||||||
|
let season_str = params.season.map(|s| s.to_string());
|
||||||
|
let episode_str = params.episode.map(|e| e.to_string());
|
||||||
|
let year_str = params.year.map(|y| y.to_string());
|
||||||
|
|
||||||
|
let mut query: Vec<(&str, &str)> = vec![
|
||||||
|
("query", params.query.as_str()),
|
||||||
|
("languages", params.language.as_str()),
|
||||||
|
];
|
||||||
|
if let Some(season) = &season_str {
|
||||||
|
query.push(("season_number", season));
|
||||||
|
}
|
||||||
|
if let Some(episode) = &episode_str {
|
||||||
|
query.push(("episode_number", episode));
|
||||||
|
}
|
||||||
|
if let Some(year) = &year_str {
|
||||||
|
query.push(("year", year));
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!("{}/subtitles", self.base_url);
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.header("Api-Key", &self.api_key)
|
||||||
|
.query(&query)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| OpensubtitlesError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let message = response.text().await.unwrap_or_default();
|
||||||
|
return Err(OpensubtitlesError::Api { status, message });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: SearchResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| OpensubtitlesError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(body
|
||||||
|
.data
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|hit| {
|
||||||
|
let file_id = hit.attributes.files.first()?.file_id;
|
||||||
|
Some(SubtitleResult {
|
||||||
|
file_id,
|
||||||
|
release: hit.attributes.release,
|
||||||
|
language: hit.attributes.language.unwrap_or_default(),
|
||||||
|
download_count: hit.attributes.download_count,
|
||||||
|
hearing_impaired: hit.attributes.hearing_impaired,
|
||||||
|
hd: hit.attributes.hd,
|
||||||
|
moviehash_match: hit.attributes.moviehash_match,
|
||||||
|
uploader: hit.attributes.uploader.and_then(|u| u.name),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logs in with the end user's own OpenSubtitles account, returning a short-lived
|
||||||
|
/// JWT to pass to `download`. Required only for downloading, never for searching.
|
||||||
|
pub async fn login(&self, username: &str, password: &str) -> Result<String, OpensubtitlesError> {
|
||||||
|
let url = format!("{}/login", self.base_url);
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.header("Api-Key", &self.api_key)
|
||||||
|
.json(&LoginRequest { username, password })
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| OpensubtitlesError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if status.as_u16() == 401 {
|
||||||
|
return Err(OpensubtitlesError::InvalidCredentials);
|
||||||
|
}
|
||||||
|
if !status.is_success() {
|
||||||
|
let message = response.text().await.unwrap_or_default();
|
||||||
|
return Err(OpensubtitlesError::Api {
|
||||||
|
status: status.as_u16(),
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: LoginResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| OpensubtitlesError::Network(e.to_string()))?;
|
||||||
|
Ok(body.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exchanges a search result's `file_id` for a temporary download link, given a
|
||||||
|
/// login token from `login`. The returned `remaining`/`allowed` reflect the
|
||||||
|
/// account's daily download quota *after* this download.
|
||||||
|
pub async fn download(&self, token: &str, file_id: u64) -> Result<SubtitleDownload, OpensubtitlesError> {
|
||||||
|
let url = format!("{}/download", self.base_url);
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.header("Api-Key", &self.api_key)
|
||||||
|
.header("Authorization", format!("Bearer {token}"))
|
||||||
|
.json(&DownloadRequest { file_id })
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| OpensubtitlesError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let message = response.text().await.unwrap_or_default();
|
||||||
|
return Err(OpensubtitlesError::Api { status, message });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: DownloadResponseBody = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| OpensubtitlesError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(SubtitleDownload {
|
||||||
|
link: body.link,
|
||||||
|
file_name: body.file_name,
|
||||||
|
remaining: body.remaining,
|
||||||
|
allowed: body.allowed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches the actual subtitle text from a `download()` result's temporary `link`.
|
||||||
|
/// Subtitle files (SRT/VTT) are plain text, so this returns a `String` rather than
|
||||||
|
/// raw bytes.
|
||||||
|
pub async fn fetch_subtitle_text(&self, link: &str) -> Result<String, OpensubtitlesError> {
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.get(link)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| OpensubtitlesError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let message = response.text().await.unwrap_or_default();
|
||||||
|
return Err(OpensubtitlesError::Api { status, message });
|
||||||
|
}
|
||||||
|
|
||||||
|
response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| OpensubtitlesError::Network(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn params(query: &str) -> SearchParams {
|
||||||
|
SearchParams {
|
||||||
|
query: query.to_string(),
|
||||||
|
season: None,
|
||||||
|
episode: None,
|
||||||
|
year: None,
|
||||||
|
language: "en".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn searches_and_flattens_results() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _mock = server
|
||||||
|
.mock("GET", "/subtitles")
|
||||||
|
.match_query(mockito::Matcher::AllOf(vec![
|
||||||
|
mockito::Matcher::UrlEncoded("query".into(), "Girls".into()),
|
||||||
|
mockito::Matcher::UrlEncoded("languages".into(), "en".into()),
|
||||||
|
]))
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(
|
||||||
|
r#"{"data":[{"attributes":{"release":"Girls.S02E02.WEB-DL","language":"en",
|
||||||
|
"download_count":42,"hearing_impaired":false,"hd":true,"moviehash_match":true,
|
||||||
|
"uploader":{"name":"someone"},"files":[{"file_id":9001}]}}]}"#,
|
||||||
|
)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
|
||||||
|
let results = client.search(¶ms("Girls")).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
results,
|
||||||
|
vec![SubtitleResult {
|
||||||
|
file_id: 9001,
|
||||||
|
release: "Girls.S02E02.WEB-DL".to_string(),
|
||||||
|
language: "en".to_string(),
|
||||||
|
download_count: 42,
|
||||||
|
hearing_impaired: false,
|
||||||
|
hd: true,
|
||||||
|
moviehash_match: true,
|
||||||
|
uploader: Some("someone".to_string()),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn includes_season_and_episode_in_the_query_only_when_given() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _mock = server
|
||||||
|
.mock("GET", "/subtitles")
|
||||||
|
.match_query(mockito::Matcher::AllOf(vec![
|
||||||
|
mockito::Matcher::UrlEncoded("season_number".into(), "2".into()),
|
||||||
|
mockito::Matcher::UrlEncoded("episode_number".into(), "5".into()),
|
||||||
|
]))
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"{"data":[]}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
|
||||||
|
let mut p = params("Some Show");
|
||||||
|
p.season = Some(2);
|
||||||
|
p.episode = Some(5);
|
||||||
|
let results = client.search(&p).await.unwrap();
|
||||||
|
|
||||||
|
assert!(results.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn search_results_with_no_file_attached_are_skipped() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _mock = server
|
||||||
|
.mock("GET", "/subtitles")
|
||||||
|
.match_query(mockito::Matcher::Any)
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(
|
||||||
|
r#"{"data":[
|
||||||
|
{"attributes":{"release":"No Files","language":"en","download_count":0,
|
||||||
|
"hearing_impaired":false,"hd":false,"files":[]}},
|
||||||
|
{"attributes":{"release":"Has A File","language":"en","download_count":1,
|
||||||
|
"hearing_impaired":false,"hd":false,"files":[{"file_id":5}]}}
|
||||||
|
]}"#,
|
||||||
|
)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
|
||||||
|
let results = client.search(¶ms("anything")).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].release, "Has A File");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn surfaces_a_server_error_on_search_as_an_api_error() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _mock = server
|
||||||
|
.mock("GET", "/subtitles")
|
||||||
|
.match_query(mockito::Matcher::Any)
|
||||||
|
.with_status(503)
|
||||||
|
.with_header("content-type", "text/plain")
|
||||||
|
.with_body("service unavailable")
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
|
||||||
|
let result = client.search(¶ms("anything")).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Err(OpensubtitlesError::Api { status, .. }) => assert_eq!(status, 503),
|
||||||
|
other => panic!("expected Api error, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn logs_in_successfully_and_returns_the_token() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _mock = server
|
||||||
|
.mock("POST", "/login")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"{"token":"jwt-abc-123","user":{"allowed_downloads":100}}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
|
||||||
|
let token = client.login("someone", "hunter2").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(token, "jwt-abc-123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_with_bad_credentials_reports_invalid_credentials() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _mock = server
|
||||||
|
.mock("POST", "/login")
|
||||||
|
.with_status(401)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"{"message":"Unauthorized"}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
|
||||||
|
let result = client.login("someone", "wrong-password").await;
|
||||||
|
|
||||||
|
assert!(matches!(result, Err(OpensubtitlesError::InvalidCredentials)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn downloads_and_returns_the_link_and_quota() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _mock = server
|
||||||
|
.mock("POST", "/download")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(
|
||||||
|
r#"{"link":"https://dl.opensubtitles.com/tmp/abc.srt","file_name":"Girls.S02E02.srt",
|
||||||
|
"remaining":99,"allowed":100}"#,
|
||||||
|
)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
|
||||||
|
let download = client.download("jwt-abc-123", 9001).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
download,
|
||||||
|
SubtitleDownload {
|
||||||
|
link: "https://dl.opensubtitles.com/tmp/abc.srt".to_string(),
|
||||||
|
file_name: "Girls.S02E02.srt".to_string(),
|
||||||
|
remaining: 99,
|
||||||
|
allowed: 100,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn download_failure_surfaces_as_an_api_error() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _mock = server
|
||||||
|
.mock("POST", "/download")
|
||||||
|
.with_status(406)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"{"message":"Daily download limit reached"}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
|
||||||
|
let result = client.download("jwt-abc-123", 9001).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Err(OpensubtitlesError::Api { status, message }) => {
|
||||||
|
assert_eq!(status, 406);
|
||||||
|
assert!(message.contains("limit"));
|
||||||
|
}
|
||||||
|
other => panic!("expected Api error, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetches_the_actual_subtitle_text_from_a_download_link() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _mock = server
|
||||||
|
.mock("GET", "/tmp/abc.srt")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "text/plain")
|
||||||
|
.with_body("1\n00:00:01,000 --> 00:00:02,000\nHello\n")
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
|
||||||
|
let text = client
|
||||||
|
.fetch_subtitle_text(&format!("{}/tmp/abc.srt", server.url()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(text.contains("Hello"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ struct SearchTvResponse {
|
|||||||
results: Vec<TvResult>,
|
results: Vec<TvResult>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One TMDB search hit. Only the fields Renamr actually uses are kept.
|
/// One TMDB search hit. Only the fields Aurora Curator actually uses are kept.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct TvResult {
|
pub struct TvResult {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
@@ -75,7 +75,7 @@ struct SearchMovieResponse {
|
|||||||
results: Vec<MovieResult>,
|
results: Vec<MovieResult>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One TMDB movie search hit. Only the fields Renamr actually uses are kept.
|
/// One TMDB movie search hit. Only the fields Aurora Curator actually uses are kept.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct MovieResult {
|
pub struct MovieResult {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
//! A small client for [TVmaze](https://www.tvmaze.com/)'s free REST API: search for a
|
||||||
|
//! TV show by name, then look up a specific episode's real title/air date.
|
||||||
|
//!
|
||||||
|
//! TV-only (TVmaze doesn't have a movie catalog at all, unlike TMDB) and, unlike TMDB,
|
||||||
|
//! needs **no API key** - every endpoint used here is fully public. That makes it a
|
||||||
|
//! zero-setup second TV metadata source: a user who doesn't want to register for a TMDB
|
||||||
|
//! token, or whose show just isn't on TMDB, can pick this instead in Fetch Data
|
||||||
|
//! Settings. See `crate::web::tmdb` for the TMDB client this mirrors the shape of.
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::similarity::best_title_match;
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL: &str = "https://api.tvmaze.com";
|
||||||
|
|
||||||
|
/// Same threshold TMDB's client uses (see `tmdb::NAME_MATCH_THRESHOLD`) - kept
|
||||||
|
/// identical so switching sources doesn't change how forgiving/strict name matching
|
||||||
|
/// feels to the user.
|
||||||
|
const NAME_MATCH_THRESHOLD: f64 = 0.35;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum TvmazeError {
|
||||||
|
#[error("network error talking to TVmaze: {0}")]
|
||||||
|
Network(String),
|
||||||
|
#[error("TVmaze API error (status {status}): {message}")]
|
||||||
|
Api { status: u16, message: String },
|
||||||
|
#[error("no TVmaze match found for \"{0}\"")]
|
||||||
|
NotFound(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One TVmaze search hit's show data. Only the fields Aurora Curator actually uses are
|
||||||
|
/// kept - TVmaze's `/search/shows` wraps each show in a `{score, show: {...}}` envelope
|
||||||
|
/// (the score is TVmaze's own relevance ranking, not used here - `best_title_match`
|
||||||
|
/// re-ranks by name similarity instead, same as the TMDB client does).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct TvResult {
|
||||||
|
pub id: u64,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct SearchHit {
|
||||||
|
show: TvResult,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct EpisodeResult {
|
||||||
|
season: i32,
|
||||||
|
number: Option<i32>,
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
airdate: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real metadata for one episode, resolved from TVmaze - same shape as
|
||||||
|
/// `tmdb::EpisodeInfo` so the Tauri glue can treat either source's result identically.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct EpisodeInfo {
|
||||||
|
pub series_name: String,
|
||||||
|
pub episode_title: String,
|
||||||
|
pub air_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One episode of a season, as returned by `season_episodes()` - mirrors
|
||||||
|
/// `tmdb::SeasonEpisode`'s field names so both clients' results map onto the same local
|
||||||
|
/// shape in the Tauri glue.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SeasonEpisode {
|
||||||
|
pub episode_number: i32,
|
||||||
|
pub title: String,
|
||||||
|
pub air_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TvmazeClient {
|
||||||
|
http: reqwest::Client,
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TvmazeClient {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TvmazeClient {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
http: reqwest::Client::new(),
|
||||||
|
base_url: DEFAULT_BASE_URL.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overrides the API base URL - used by tests to point at a local mock server
|
||||||
|
/// instead of the real TVmaze API.
|
||||||
|
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||||
|
self.base_url = base_url.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_json<T: for<'de> Deserialize<'de>>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
query: &[(&str, &str)],
|
||||||
|
) -> Result<T, TvmazeError> {
|
||||||
|
let url = format!("{}{path}", self.base_url);
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.query(query)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| TvmazeError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let message = response.text().await.unwrap_or_default();
|
||||||
|
return Err(TvmazeError::Api { status, message });
|
||||||
|
}
|
||||||
|
|
||||||
|
response
|
||||||
|
.json::<T>()
|
||||||
|
.await
|
||||||
|
.map_err(|e| TvmazeError::Network(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Searches for a TV show by name. Results come back TVmaze-ranked by its own
|
||||||
|
/// relevance score; callers wanting "the best guess" should use
|
||||||
|
/// `search_tv_best_match` instead, same convention as the TMDB client.
|
||||||
|
pub async fn search_tv(&self, query: &str) -> Result<Vec<TvResult>, TvmazeError> {
|
||||||
|
let hits: Vec<SearchHit> = self.get_json("/search/shows", &[("q", query)]).await?;
|
||||||
|
Ok(hits.into_iter().map(|h| h.show).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches EVERY episode of a show (all seasons) in a single request - TVmaze's
|
||||||
|
/// `/shows/{id}/episodes` endpoint doesn't take a season filter, unlike TMDB's
|
||||||
|
/// per-season endpoint, so this fetches once and filters locally. Prefer calling
|
||||||
|
/// this once and reusing the result over calling it per-season/per-episode.
|
||||||
|
async fn all_episodes(&self, series_id: u64) -> Result<Vec<EpisodeResult>, TvmazeError> {
|
||||||
|
let path = format!("/shows/{series_id}/episodes");
|
||||||
|
self.get_json(&path, &[]).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches one season's episodes for a known TVmaze show id - mirrors
|
||||||
|
/// `TmdbClient::season_episodes`'s signature/return shape even though the
|
||||||
|
/// underlying TVmaze call fetches (and then filters) every season at once.
|
||||||
|
pub async fn season_episodes(&self, series_id: u64, season: i32) -> Result<Vec<SeasonEpisode>, TvmazeError> {
|
||||||
|
let episodes = self.all_episodes(series_id).await?;
|
||||||
|
Ok(episodes
|
||||||
|
.into_iter()
|
||||||
|
.filter(|e| e.season == season && e.number.is_some())
|
||||||
|
.map(|e| SeasonEpisode {
|
||||||
|
episode_number: e.number.expect("filtered to Some above"),
|
||||||
|
title: e.name,
|
||||||
|
air_date: e.airdate,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Searches for a TV show by name and returns the single best-matching result (see
|
||||||
|
/// `similarity::best_title_match`), or `None` if nothing in the results is a
|
||||||
|
/// plausible match at all. Mirrors `TmdbClient::search_tv_best_match`.
|
||||||
|
pub async fn search_tv_best_match(&self, query: &str) -> Result<Option<TvResult>, TvmazeError> {
|
||||||
|
let results = self.search_tv(query).await?;
|
||||||
|
Ok(best_title_match(query, &results, |r| r.name.as_str(), NAME_MATCH_THRESHOLD).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: search for the best-matching show by name, then fetch a specific
|
||||||
|
/// episode. Same disambiguation approach as `TmdbClient::lookup_episode` - picks
|
||||||
|
/// whichever result's name is textually closest to `series_query` rather than
|
||||||
|
/// trusting TVmaze's own top-ranked result, and reports `NotFound` instead of
|
||||||
|
/// guessing when nothing is a plausible match.
|
||||||
|
pub async fn lookup_episode(
|
||||||
|
&self,
|
||||||
|
series_query: &str,
|
||||||
|
season: i32,
|
||||||
|
episode: i32,
|
||||||
|
) -> Result<EpisodeInfo, TvmazeError> {
|
||||||
|
let best = self
|
||||||
|
.search_tv_best_match(series_query)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| TvmazeError::NotFound(series_query.to_string()))?;
|
||||||
|
|
||||||
|
let episodes = self.season_episodes(best.id, season).await?;
|
||||||
|
let found = episodes
|
||||||
|
.into_iter()
|
||||||
|
.find(|e| e.episode_number == episode)
|
||||||
|
.ok_or_else(|| TvmazeError::NotFound(format!("S{season:02}E{episode:02}")))?;
|
||||||
|
|
||||||
|
Ok(EpisodeInfo {
|
||||||
|
series_name: best.name,
|
||||||
|
episode_title: found.title,
|
||||||
|
air_date: found.air_date,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn looks_up_a_real_episode_end_to_end() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _search_mock = server
|
||||||
|
.mock("GET", "/search/shows")
|
||||||
|
.match_query(mockito::Matcher::UrlEncoded("q".into(), "Girls".into()))
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"[{"score":10.5,"show":{"id":139,"name":"Girls"}}]"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// TVmaze returns every season's episodes in one call - the mock reflects that,
|
||||||
|
// with episodes from two different seasons mixed together to prove filtering
|
||||||
|
// by season actually happens rather than just taking whatever's returned.
|
||||||
|
let _episodes_mock = server
|
||||||
|
.mock("GET", "/shows/139/episodes")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(
|
||||||
|
r#"[
|
||||||
|
{"season":1,"number":1,"name":"Pilot","airdate":"2012-04-15"},
|
||||||
|
{"season":2,"number":1,"name":"It's About Time","airdate":"2013-01-13"},
|
||||||
|
{"season":2,"number":2,"name":"I Get Ideas","airdate":"2013-01-20"}
|
||||||
|
]"#,
|
||||||
|
)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = TvmazeClient::new().with_base_url(server.url());
|
||||||
|
let info = client.lookup_episode("Girls", 2, 2).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
info,
|
||||||
|
EpisodeInfo {
|
||||||
|
series_name: "Girls".to_string(),
|
||||||
|
episode_title: "I Get Ideas".to_string(),
|
||||||
|
air_date: Some("2013-01-20".to_string()),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reports_not_found_when_search_has_no_results() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _mock = server
|
||||||
|
.mock("GET", "/search/shows")
|
||||||
|
.match_query(mockito::Matcher::Any)
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body("[]")
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = TvmazeClient::new().with_base_url(server.url());
|
||||||
|
let result = client.lookup_episode("Some Nonexistent Show", 1, 1).await;
|
||||||
|
|
||||||
|
assert!(matches!(result, Err(TvmazeError::NotFound(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reports_not_found_when_the_season_has_no_such_episode() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _search_mock = server
|
||||||
|
.mock("GET", "/search/shows")
|
||||||
|
.match_query(mockito::Matcher::Any)
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"[{"score":1.0,"show":{"id":42,"name":"Some Show"}}]"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let _episodes_mock = server
|
||||||
|
.mock("GET", "/shows/42/episodes")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"[{"season":1,"number":1,"name":"Pilot","airdate":"2020-01-01"}]"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = TvmazeClient::new().with_base_url(server.url());
|
||||||
|
// Season 1 only has episode 1 in this mock - asking for episode 99 should miss.
|
||||||
|
let result = client.lookup_episode("Some Show", 1, 99).await;
|
||||||
|
|
||||||
|
assert!(matches!(result, Err(TvmazeError::NotFound(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn picks_the_exact_name_match_over_a_more_popular_but_wrong_result() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _search_mock = server
|
||||||
|
.mock("GET", "/search/shows")
|
||||||
|
.match_query(mockito::Matcher::UrlEncoded("q".into(), "Chuck".into()))
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"[{"score":20.0,"show":{"id":1,"name":"Chucky"}},{"score":5.0,"show":{"id":2,"name":"Chuck"}}]"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let _episodes_mock = server
|
||||||
|
.mock("GET", "/shows/2/episodes")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"[{"season":1,"number":1,"name":"Chuck Versus the Intersect","airdate":"2007-09-24"}]"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = TvmazeClient::new().with_base_url(server.url());
|
||||||
|
let info = client.lookup_episode("Chuck", 1, 1).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(info.series_name, "Chuck");
|
||||||
|
assert_eq!(info.episode_title, "Chuck Versus the Intersect");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reports_not_found_when_no_search_result_is_a_plausible_name_match() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _mock = server
|
||||||
|
.mock("GET", "/search/shows")
|
||||||
|
.match_query(mockito::Matcher::Any)
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"[{"score":1.0,"show":{"id":9,"name":"Totally Unrelated Program"}}]"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = TvmazeClient::new().with_base_url(server.url());
|
||||||
|
let result = client.lookup_episode("Zzyzx Nonexistent Show", 1, 1).await;
|
||||||
|
|
||||||
|
assert!(matches!(result, Err(TvmazeError::NotFound(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn episodes_with_no_episode_number_are_skipped_rather_than_panicking() {
|
||||||
|
// TVmaze occasionally has "special"/non-numbered entries (e.g. season 0
|
||||||
|
// specials, or episodes with number:null) mixed in with regular episodes -
|
||||||
|
// make sure filtering by season doesn't choke on one, since a naive `.unwrap()`
|
||||||
|
// on `number` would panic on real data.
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _search_mock = server
|
||||||
|
.mock("GET", "/search/shows")
|
||||||
|
.match_query(mockito::Matcher::Any)
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"[{"score":1.0,"show":{"id":7,"name":"Some Show"}}]"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let _episodes_mock = server
|
||||||
|
.mock("GET", "/shows/7/episodes")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(
|
||||||
|
r#"[
|
||||||
|
{"season":1,"number":null,"name":"Uncategorized Special","airdate":null},
|
||||||
|
{"season":1,"number":1,"name":"Pilot","airdate":"2021-01-01"}
|
||||||
|
]"#,
|
||||||
|
)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = TvmazeClient::new().with_base_url(server.url());
|
||||||
|
let info = client.lookup_episode("Some Show", 1, 1).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(info.episode_title, "Pilot");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn surfaces_a_server_error_as_an_api_error() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
let _mock = server
|
||||||
|
.mock("GET", "/search/shows")
|
||||||
|
.match_query(mockito::Matcher::Any)
|
||||||
|
.with_status(503)
|
||||||
|
.with_header("content-type", "text/plain")
|
||||||
|
.with_body("service unavailable")
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = TvmazeClient::new().with_base_url(server.url());
|
||||||
|
let result = client.search_tv("anything").await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Err(TvmazeError::Api { status, .. }) => assert_eq!(status, 503),
|
||||||
|
other => panic!("expected Api error, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use renamr_core::format::ExpressionFormat;
|
use aurora_curator_core::format::ExpressionFormat;
|
||||||
use renamr_core::string_bindings;
|
use aurora_curator_core::string_bindings;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn simple_variable_substitution() {
|
fn simple_variable_substitution() {
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
not actually a valid mp4 container, just bytes with an .mp4 extension
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
this is not a media file, just plain text used to test that probing degrades gracefully
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
//! Online metadata clients ("Fetch Data"). Currently just TMDB; the original FileBot
|
|
||||||
//! supports ~40 sources (`net.filebot.web.*`) - TMDB was picked first since it's the
|
|
||||||
//! most widely useful for both TV and movies and has a simple, well-documented API.
|
|
||||||
|
|
||||||
pub mod tmdb;
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Renamr</title>
|
<title>Aurora Curator</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "renamr",
|
"name": "aurora-curator",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "renamr",
|
"name": "aurora-curator",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.1.1",
|
"@tauri-apps/api": "^2.1.1",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "renamr",
|
"name": "aurora-curator",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,46 +1,24 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Generates Renamr's app icon set (placeholder branding, easy to swap later).
|
"""Generates Aurora Curator's app icon set from the real logo mark.
|
||||||
|
|
||||||
|
Source master: scripts/logo/icon-source.png (the folder/aurora/filmstrip mark,
|
||||||
|
square-padded, transparent background - cropped from the full lockup that also
|
||||||
|
includes the "AURORA CURATOR" wordmark, which isn't used for the app icon itself).
|
||||||
|
|
||||||
Produces the sizes Tauri's bundler expects in src-tauri/icons/.
|
Produces the sizes Tauri's bundler expects in src-tauri/icons/.
|
||||||
"""
|
"""
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image
|
||||||
import os
|
import os
|
||||||
|
|
||||||
OUT = os.path.join(os.path.dirname(__file__), "..", "src-tauri", "icons")
|
HERE = os.path.dirname(__file__)
|
||||||
|
SRC = os.path.join(HERE, "logo", "icon-source.png")
|
||||||
|
OUT = os.path.join(HERE, "..", "src-tauri", "icons")
|
||||||
os.makedirs(OUT, exist_ok=True)
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
|
||||||
SIZE = 1024
|
master = Image.open(SRC).convert("RGBA")
|
||||||
BG_TOP = (91, 76, 245) # indigo
|
if master.size != (1024, 1024):
|
||||||
BG_BOTTOM = (56, 189, 178) # teal
|
master = master.resize((1024, 1024), Image.LANCZOS)
|
||||||
FG = (255, 255, 255)
|
|
||||||
|
|
||||||
def make_master():
|
|
||||||
img = Image.new("RGB", (SIZE, SIZE), BG_TOP)
|
|
||||||
px = img.load()
|
|
||||||
for y in range(SIZE):
|
|
||||||
t = y / SIZE
|
|
||||||
r = int(BG_TOP[0] * (1 - t) + BG_BOTTOM[0] * t)
|
|
||||||
g = int(BG_TOP[1] * (1 - t) + BG_BOTTOM[1] * t)
|
|
||||||
b = int(BG_TOP[2] * (1 - t) + BG_BOTTOM[2] * t)
|
|
||||||
for x in range(SIZE):
|
|
||||||
px[x, y] = (r, g, b)
|
|
||||||
|
|
||||||
# rounded-square mask
|
|
||||||
mask = Image.new("L", (SIZE, SIZE), 0)
|
|
||||||
ImageDraw.Draw(mask).rounded_rectangle([0, 0, SIZE - 1, SIZE - 1], radius=SIZE // 5, fill=255)
|
|
||||||
canvas = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
|
||||||
canvas.paste(img, (0, 0), mask)
|
|
||||||
|
|
||||||
draw = ImageDraw.Draw(canvas)
|
|
||||||
font = ImageFont.truetype("/usr/share/fonts/truetype/google-fonts/Poppins-Bold.ttf", int(SIZE * 0.56))
|
|
||||||
text = "R"
|
|
||||||
bbox = draw.textbbox((0, 0), text, font=font)
|
|
||||||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
||||||
draw.text(((SIZE - tw) / 2 - bbox[0], (SIZE - th) / 2 - bbox[1] - SIZE * 0.02), text, font=font, fill=FG)
|
|
||||||
|
|
||||||
return canvas
|
|
||||||
|
|
||||||
master = make_master()
|
|
||||||
master.save(os.path.join(OUT, "icon-master.png"))
|
master.save(os.path.join(OUT, "icon-master.png"))
|
||||||
|
|
||||||
sizes = [32, 128, 256, 512, 1024]
|
sizes = [32, 128, 256, 512, 1024]
|
||||||
@@ -62,4 +40,4 @@ try:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"WARN: could not write icon.icns via Pillow ({e}); leaving it out")
|
print(f"WARN: could not write icon.icns via Pillow ({e}); leaving it out")
|
||||||
|
|
||||||
print("done:", os.listdir(OUT))
|
print("done:", sorted(os.listdir(OUT)))
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 774 KiB |
@@ -1,21 +1,22 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "renamr"
|
name = "aurora-curator"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "renamr_lib"
|
name = "aurora_curator_lib"
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
renamr-core = { path = "../crates/renamr-core" }
|
aurora-curator-core = { path = "../crates/aurora-curator-core" }
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
tokio = { version = "1", features = ["sync"] }
|
tokio = { version = "1", features = ["sync"] }
|
||||||
|
dirs = "6"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Default permissions for Renamr's main window.",
|
"description": "Default permissions for Aurora Curator's main window.",
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": ["core:default", "dialog:default"]
|
"permissions": ["core:default", "dialog:default"]
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 774 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 774 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 102 KiB |
@@ -0,0 +1,551 @@
|
|||||||
|
//! Orchestration for the `-rename` command-line mode: ties together the pure argument
|
||||||
|
//! parsing/file-expansion in `aurora_curator_core::cli` with this crate's own
|
||||||
|
//! Tauri-app-free pieces (`settings::cli_tmdb_token`, `metadata::AnyTvClient`) to
|
||||||
|
//! actually build a rename plan and execute it - all without ever constructing a Tauri
|
||||||
|
//! `App`/`Builder`. `main.rs` only calls [`run`] after confirming (via
|
||||||
|
//! `aurora_curator_core::cli::looks_like_cli_invocation`) that this is a `-rename`/
|
||||||
|
//! `-h`/`--help` invocation in the first place; a plain GUI launch never reaches this
|
||||||
|
//! module at all.
|
||||||
|
//!
|
||||||
|
//! Async network calls (TMDB/TVmaze lookups) run via `tauri::async_runtime::block_on`
|
||||||
|
//! rather than a directly-owned tokio runtime - confirmed by reading Tauri 2.11's own
|
||||||
|
//! `async_runtime.rs` that this lazily creates its own global `Runtime` via `OnceLock`,
|
||||||
|
//! fully independent of any running `App`, so it's safe to call here with no Tauri app
|
||||||
|
//! ever built. This also avoids adding a `rt`/`macros` tokio feature to this crate just
|
||||||
|
//! for the CLI path.
|
||||||
|
//!
|
||||||
|
//! [`build_plan`] is this module's Rust-side port of the binding vocabulary
|
||||||
|
//! `RenamePanel.tsx` builds client-side in the browser (`{n}`, `{s00e00}`, `{show
|
||||||
|
//! name}`, `{episode name}`, release-tag fields, etc.) - see that file's `runMatching`/
|
||||||
|
//! Fetch Data handlers, which this deliberately mirrors field-for-field, so a naming
|
||||||
|
//! scheme that works in the GUI renders identically from the command line.
|
||||||
|
|
||||||
|
use aurora_curator_core::cli::{self, CliInvocation, RenameCliArgs, TvDbChoice};
|
||||||
|
use aurora_curator_core::format::ExpressionFormat;
|
||||||
|
use aurora_curator_core::matching::{self, ReleaseInfo};
|
||||||
|
use aurora_curator_core::rename::{self, RenameOutcome, RenamePlanItem};
|
||||||
|
use aurora_curator_core::string_bindings;
|
||||||
|
use aurora_curator_core::web::omdb::OmdbClient;
|
||||||
|
use aurora_curator_core::web::tmdb::TmdbClient;
|
||||||
|
use aurora_curator_core::web::tvmaze::TvmazeClient;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::metadata::{AnyMovieClient, AnyTvClient, MovieLookup};
|
||||||
|
use crate::settings;
|
||||||
|
|
||||||
|
const USAGE: &str = "\
|
||||||
|
Aurora Curator - command-line rename mode
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
aurora-curator -rename --format <naming scheme> [options] <file-or-folder>...
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--format <expr> Naming scheme, e.g. \"{show name} - {season number}x{episode number} - {episode name}\" (required)
|
||||||
|
--db <tmdb|tvmaze|omdb> Resolve TV episodes online against this source before rendering names (optional;
|
||||||
|
movies are also looked up online when this is tmdb or omdb - TVmaze has no movie catalog)
|
||||||
|
--action <move|copy|symlink|test> What to do with each file (default: move; \"test\" prints the plan without touching anything)
|
||||||
|
--conflict <skip|auto-number|override> What to do when a computed target already exists (default: skip)
|
||||||
|
-non-strict Accepted for FileBot script compatibility; has no effect (local matching has no
|
||||||
|
confidence score to loosen - see the aurora_curator_core::cli module doc)
|
||||||
|
-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
|
||||||
|
Settings at least once - the CLI never accepts a token or key as a command-line argument.
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Entry point called from `main.rs`. Returns the process exit code.
|
||||||
|
pub fn run(args: Vec<String>) -> i32 {
|
||||||
|
match cli::parse_args(&args) {
|
||||||
|
Ok(CliInvocation::Gui) => {
|
||||||
|
// `main.rs` only calls `run()` after `looks_like_cli_invocation` already
|
||||||
|
// confirmed a trigger flag is present, so this arm shouldn't be reachable
|
||||||
|
// in practice - but returning cleanly here is safer than panicking if it
|
||||||
|
// ever is.
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Ok(CliInvocation::Help) => {
|
||||||
|
print!("{USAGE}");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Ok(CliInvocation::Rename(rename_args)) => run_rename(rename_args),
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("error: {err}");
|
||||||
|
eprintln!();
|
||||||
|
eprint!("{USAGE}");
|
||||||
|
2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_rename(args: RenameCliArgs) -> i32 {
|
||||||
|
let files = cli::expand_inputs(&args.inputs);
|
||||||
|
if files.is_empty() {
|
||||||
|
eprintln!("error: no files found under the given input(s)");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let compiled = match ExpressionFormat::compile(&args.format) {
|
||||||
|
Ok(compiled) => compiled,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("error: invalid --format expression: {e}");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolved once up front (not per-file) so a batch never ends up split across two
|
||||||
|
// different saved-token states, and so a missing token/key fails fast instead of
|
||||||
|
// after however many files were already processed.
|
||||||
|
let tmdb_token = if args.db == Some(TvDbChoice::Tmdb) {
|
||||||
|
match settings::cli_tmdb_token() {
|
||||||
|
Some(token) => Some(token),
|
||||||
|
None => {
|
||||||
|
eprintln!(
|
||||||
|
"error: --db tmdb needs a TMDB token already saved through the app's own Fetch Data Settings"
|
||||||
|
);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let omdb_api_key = if args.db == Some(TvDbChoice::Omdb) {
|
||||||
|
match settings::cli_omdb_api_key() {
|
||||||
|
Some(key) => Some(key),
|
||||||
|
None => {
|
||||||
|
eprintln!(
|
||||||
|
"error: --db omdb needs an OMDb API key already saved through the app's own Fetch Data Settings"
|
||||||
|
);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let tv_client = match args.db {
|
||||||
|
None => None,
|
||||||
|
Some(TvDbChoice::Tvmaze) => Some(AnyTvClient::Tvmaze(TvmazeClient::new())),
|
||||||
|
Some(TvDbChoice::Tmdb) => Some(AnyTvClient::Tmdb(TmdbClient::new(
|
||||||
|
tmdb_token.clone().expect("checked above"),
|
||||||
|
))),
|
||||||
|
Some(TvDbChoice::Omdb) => Some(AnyTvClient::Omdb(OmdbClient::new(
|
||||||
|
omdb_api_key.clone().expect("checked above"),
|
||||||
|
))),
|
||||||
|
};
|
||||||
|
// Movies get an online lookup whenever --db tmdb or --db omdb was explicitly
|
||||||
|
// passed - TVmaze has no movie catalog, and no --db at all means "local matching
|
||||||
|
// only" for both.
|
||||||
|
let movie_client = match args.db {
|
||||||
|
Some(TvDbChoice::Tmdb) => Some(AnyMovieClient::Tmdb(TmdbClient::new(
|
||||||
|
tmdb_token.clone().expect("checked above"),
|
||||||
|
))),
|
||||||
|
Some(TvDbChoice::Omdb) => Some(AnyMovieClient::Omdb(OmdbClient::new(
|
||||||
|
omdb_api_key.clone().expect("checked above"),
|
||||||
|
))),
|
||||||
|
Some(TvDbChoice::Tvmaze) | None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let items = tauri::async_runtime::block_on(build_plan(&files, &compiled, tv_client, movie_client));
|
||||||
|
if items.is_empty() {
|
||||||
|
eprintln!("error: every file's naming scheme failed to render - nothing to rename");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.dry_run {
|
||||||
|
for item in &items {
|
||||||
|
println!("{} -> {}", item.source, item.new_name);
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"{} file(s) would be renamed (--action test: nothing was touched)",
|
||||||
|
items.len()
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = rename::execute_renames(&items, args.action, args.conflict);
|
||||||
|
print_summary(&results)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_summary(results: &[rename::RenameResult]) -> i32 {
|
||||||
|
let mut renamed = 0;
|
||||||
|
let mut unchanged = 0;
|
||||||
|
let mut skipped = 0;
|
||||||
|
let mut failed = 0;
|
||||||
|
|
||||||
|
for result in results {
|
||||||
|
match &result.outcome {
|
||||||
|
RenameOutcome::Renamed { new_path } => {
|
||||||
|
println!("{} -> {new_path}", result.source);
|
||||||
|
renamed += 1;
|
||||||
|
}
|
||||||
|
RenameOutcome::Unchanged => unchanged += 1,
|
||||||
|
RenameOutcome::SkippedConflict { existing_path } => {
|
||||||
|
eprintln!("skipped (target already exists): {} -> {existing_path}", result.source);
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
|
RenameOutcome::Failed { error } => {
|
||||||
|
eprintln!("failed: {} ({error})", result.source);
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{renamed} renamed, {unchanged} unchanged, {skipped} skipped, {failed} failed");
|
||||||
|
if failed > 0 {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `path`'s file stem and (lowercased, dot-free) extension - the same split
|
||||||
|
/// `src-tauri/src/files.rs`'s `to_file_entry` does for the GUI's own file list, so the
|
||||||
|
/// `{name}`/`{ext}` bindings and the extension appended to every rendered name match
|
||||||
|
/// what the GUI would've produced for the same file.
|
||||||
|
fn split_stem_ext(path: &Path) -> (String, String) {
|
||||||
|
let name = path
|
||||||
|
.file_stem()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| path.to_string_lossy().to_string());
|
||||||
|
let ext = path
|
||||||
|
.extension()
|
||||||
|
.map(|s| s.to_string_lossy().to_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
(name, ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_release_bindings(bindings: &mut HashMap<String, String>, release: &ReleaseInfo) {
|
||||||
|
if let Some(resolution) = &release.resolution {
|
||||||
|
bindings.insert("vf".to_string(), resolution.clone());
|
||||||
|
bindings.insert("resolution".to_string(), resolution.clone());
|
||||||
|
}
|
||||||
|
if let Some(source) = &release.source {
|
||||||
|
bindings.insert("source".to_string(), source.clone());
|
||||||
|
}
|
||||||
|
if let Some(video_codec) = &release.video_codec {
|
||||||
|
bindings.insert("vc".to_string(), video_codec.clone());
|
||||||
|
bindings.insert("video codec".to_string(), video_codec.clone());
|
||||||
|
}
|
||||||
|
if let Some(audio_codec) = &release.audio_codec {
|
||||||
|
bindings.insert("ac".to_string(), audio_codec.clone());
|
||||||
|
bindings.insert("audio codec".to_string(), audio_codec.clone());
|
||||||
|
}
|
||||||
|
if let Some(group) = &release.group {
|
||||||
|
bindings.insert("group".to_string(), group.clone());
|
||||||
|
bindings.insert("release group".to_string(), group.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the on-disk rename plan for `files`: local matching first (mirrors the GUI's
|
||||||
|
/// own Match step), then an optional online lookup per file when a client was passed
|
||||||
|
/// (mirrors Fetch Data) - see the module doc for why the binding names below have to
|
||||||
|
/// match `RenamePanel.tsx`'s exactly.
|
||||||
|
async fn build_plan(
|
||||||
|
files: &[PathBuf],
|
||||||
|
compiled: &ExpressionFormat,
|
||||||
|
tv_client: Option<AnyTvClient>,
|
||||||
|
movie_client: Option<AnyMovieClient>,
|
||||||
|
) -> Vec<RenamePlanItem> {
|
||||||
|
// Cache per (series_query, season), same as the GUI's own `run_fetch` - a whole
|
||||||
|
// season's worth of files only needs one online search + episode-list fetch.
|
||||||
|
let mut season_cache: HashMap<(String, i32), crate::metadata::SeasonLookup> = HashMap::new();
|
||||||
|
let mut items = Vec::with_capacity(files.len());
|
||||||
|
|
||||||
|
for path in files {
|
||||||
|
let filename = path
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let (name, ext) = split_stem_ext(path);
|
||||||
|
|
||||||
|
let mut bindings: HashMap<String, String> = HashMap::new();
|
||||||
|
bindings.insert("name".to_string(), name);
|
||||||
|
bindings.insert("ext".to_string(), ext.clone());
|
||||||
|
|
||||||
|
if let Some(release) = matching::guess_release_info(&filename) {
|
||||||
|
insert_release_bindings(&mut bindings, &release);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(episode) = matching::guess_episode(&filename) {
|
||||||
|
bindings.insert("n".to_string(), episode.series_name.clone());
|
||||||
|
bindings.insert("s00e00".to_string(), episode.season_episode_label());
|
||||||
|
bindings.insert("season".to_string(), episode.season.to_string());
|
||||||
|
bindings.insert("episode".to_string(), episode.episode.to_string());
|
||||||
|
bindings.insert("show name".to_string(), episode.series_name.clone());
|
||||||
|
bindings.insert("season number".to_string(), episode.season.to_string());
|
||||||
|
bindings.insert("episode number".to_string(), format!("{:02}", episode.episode));
|
||||||
|
|
||||||
|
if let Some(client) = &tv_client {
|
||||||
|
let key = (episode.series_name.clone(), episode.season);
|
||||||
|
if !season_cache.contains_key(&key) {
|
||||||
|
let looked_up = client.fetch_season(&episode.series_name, episode.season).await;
|
||||||
|
season_cache.insert(key.clone(), looked_up);
|
||||||
|
}
|
||||||
|
if let Some(Ok((series_name, episodes))) = season_cache.get(&key) {
|
||||||
|
if let Some(found) = episodes.iter().find(|e| e.episode_number == episode.episode) {
|
||||||
|
bindings.insert("n".to_string(), series_name.clone());
|
||||||
|
bindings.insert("t".to_string(), found.title.clone());
|
||||||
|
bindings.insert("show name".to_string(), series_name.clone());
|
||||||
|
bindings.insert("episode name".to_string(), found.title.clone());
|
||||||
|
if let Some(air_date) = &found.air_date {
|
||||||
|
bindings.insert("airdate".to_string(), air_date.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(movie) = matching::guess_movie(&filename) {
|
||||||
|
bindings.insert("n".to_string(), movie.title.clone());
|
||||||
|
bindings.insert("movie name".to_string(), movie.title.clone());
|
||||||
|
if let Some(year) = movie.year {
|
||||||
|
bindings.insert("y".to_string(), year.to_string());
|
||||||
|
bindings.insert("year".to_string(), year.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(client) = &movie_client {
|
||||||
|
if let Ok(MovieLookup::Found { title, release_date }) =
|
||||||
|
client.lookup_movie(&movie.title, movie.year).await
|
||||||
|
{
|
||||||
|
bindings.insert("n".to_string(), title.clone());
|
||||||
|
bindings.insert("movie name".to_string(), title);
|
||||||
|
if let Some(release_date) = &release_date {
|
||||||
|
bindings.insert("airdate".to_string(), release_date.clone());
|
||||||
|
// Only overwrite the locally-guessed year if Match didn't
|
||||||
|
// already find one in the filename itself - same rule
|
||||||
|
// RenamePanel.tsx's Fetch Data handler uses, since a
|
||||||
|
// filename-carried year is usually more precise than the
|
||||||
|
// online source's release date for disambiguating remakes.
|
||||||
|
if movie.year.is_none() {
|
||||||
|
let year: String = release_date.chars().take(4).collect();
|
||||||
|
bindings.insert("y".to_string(), year.clone());
|
||||||
|
bindings.insert("year".to_string(), year);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rendered = match compiled.format(&string_bindings(bindings)) {
|
||||||
|
Ok(rendered) => rendered,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"warning: couldn't render a name for {} ({e}) - skipping",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let new_name = if ext.is_empty() { rendered } else { format!("{rendered}.{ext}") };
|
||||||
|
|
||||||
|
items.push(RenamePlanItem {
|
||||||
|
source: path.to_string_lossy().to_string(),
|
||||||
|
new_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
|
||||||
|
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||||
|
|
||||||
|
fn temp_dir() -> PathBuf {
|
||||||
|
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let dir = std::env::temp_dir().join(format!("aurora_cli_orchestration_test_{n}_{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_plan_renders_tv_and_movie_files_locally_with_no_online_client() {
|
||||||
|
let dir = temp_dir();
|
||||||
|
let tv_file = dir.join("Breaking.Bad.S05E14.720p.HDTV.x264-KILLERS.mkv");
|
||||||
|
let movie_file = dir.join("Arrival.2016.1080p.BluRay.x264-GROUP.mkv");
|
||||||
|
std::fs::write(&tv_file, b"").unwrap();
|
||||||
|
std::fs::write(&movie_file, b"").unwrap();
|
||||||
|
|
||||||
|
let compiled = ExpressionFormat::compile("{n}").unwrap();
|
||||||
|
let items = tauri::async_runtime::block_on(build_plan(
|
||||||
|
&[tv_file.clone(), movie_file.clone()],
|
||||||
|
&compiled,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(items.len(), 2);
|
||||||
|
let tv_item = items
|
||||||
|
.iter()
|
||||||
|
.find(|i| i.source == tv_file.to_string_lossy())
|
||||||
|
.expect("tv file in plan");
|
||||||
|
assert_eq!(tv_item.new_name, "Breaking Bad.mkv");
|
||||||
|
let movie_item = items
|
||||||
|
.iter()
|
||||||
|
.find(|i| i.source == movie_file.to_string_lossy())
|
||||||
|
.expect("movie file in plan");
|
||||||
|
assert_eq!(movie_item.new_name, "Arrival.mkv");
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_plan_falls_back_to_the_bare_filename_when_nothing_is_recognized() {
|
||||||
|
let dir = temp_dir();
|
||||||
|
let file = dir.join("random_video_file.mp4");
|
||||||
|
std::fs::write(&file, b"").unwrap();
|
||||||
|
|
||||||
|
let compiled = ExpressionFormat::compile("{name}").unwrap();
|
||||||
|
let items = tauri::async_runtime::block_on(build_plan(&[file.clone()], &compiled, None, None));
|
||||||
|
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert_eq!(items[0].new_name, "random_video_file.mp4");
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_plan_includes_release_tag_bindings() {
|
||||||
|
let dir = temp_dir();
|
||||||
|
let file = dir.join("Breaking.Bad.S05E14.720p.HDTV.x264-KILLERS.mkv");
|
||||||
|
std::fs::write(&file, b"").unwrap();
|
||||||
|
|
||||||
|
let compiled = ExpressionFormat::compile("{resolution} {video codec} {release group}").unwrap();
|
||||||
|
let items = tauri::async_runtime::block_on(build_plan(&[file.clone()], &compiled, None, None));
|
||||||
|
|
||||||
|
assert_eq!(items[0].new_name, "720p x264 KILLERS.mkv");
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_prints_usage_and_returns_zero_for_help() {
|
||||||
|
assert_eq!(run(vec!["--help".to_string()]), 0);
|
||||||
|
assert_eq!(run(vec!["-rename".to_string(), "--help".to_string()]), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_returns_zero_for_a_plain_gui_invocation() {
|
||||||
|
// `main.rs` wouldn't actually call `run()` in this case (it checks
|
||||||
|
// `looks_like_cli_invocation` first), but `run()` itself stays defensive and
|
||||||
|
// returns cleanly rather than panicking if it ever is.
|
||||||
|
assert_eq!(run(vec![]), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_reports_a_parse_error_with_a_nonzero_exit_code() {
|
||||||
|
assert_eq!(run(vec!["-rename".to_string()]), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_reports_an_error_when_no_files_match_the_inputs() {
|
||||||
|
let code = run(vec![
|
||||||
|
"-rename".to_string(),
|
||||||
|
"--format".to_string(),
|
||||||
|
"{n}".to_string(),
|
||||||
|
"/definitely/not/a/real/path/anywhere".to_string(),
|
||||||
|
]);
|
||||||
|
assert_eq!(code, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_dry_run_does_not_touch_the_filesystem() {
|
||||||
|
let dir = temp_dir();
|
||||||
|
let file = dir.join("Breaking.Bad.S05E14.mkv");
|
||||||
|
std::fs::write(&file, b"hello").unwrap();
|
||||||
|
|
||||||
|
let code = run(vec![
|
||||||
|
"-rename".to_string(),
|
||||||
|
"--format".to_string(),
|
||||||
|
"{n}".to_string(),
|
||||||
|
"--action".to_string(),
|
||||||
|
"test".to_string(),
|
||||||
|
file.to_string_lossy().to_string(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(code, 0);
|
||||||
|
assert!(file.exists(), "dry run must not touch the source file");
|
||||||
|
assert!(!dir.join("Breaking Bad.mkv").exists());
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_actually_renames_a_file_with_the_default_move_action() {
|
||||||
|
let dir = temp_dir();
|
||||||
|
let file = dir.join("Breaking.Bad.S05E14.mkv");
|
||||||
|
std::fs::write(&file, b"hello").unwrap();
|
||||||
|
|
||||||
|
let code = run(vec![
|
||||||
|
"-rename".to_string(),
|
||||||
|
"--format".to_string(),
|
||||||
|
"{n}".to_string(),
|
||||||
|
file.to_string_lossy().to_string(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(code, 0);
|
||||||
|
assert!(!file.exists());
|
||||||
|
assert!(dir.join("Breaking Bad.mkv").exists());
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_fails_cleanly_when_db_tmdb_is_requested_without_a_saved_token() {
|
||||||
|
// This sandbox has no saved settings.json, so `cli_tmdb_token()` reliably
|
||||||
|
// returns None here; skip rather than false-fail if that's ever not true (e.g.
|
||||||
|
// a real token saved from a prior manual run in this same environment).
|
||||||
|
if settings::cli_tmdb_token().is_some() {
|
||||||
|
eprintln!("skipping: a real TMDB token is saved in this environment's config dir");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = temp_dir();
|
||||||
|
let file = dir.join("Breaking.Bad.S05E14.mkv");
|
||||||
|
std::fs::write(&file, b"hello").unwrap();
|
||||||
|
|
||||||
|
let code = run(vec![
|
||||||
|
"-rename".to_string(),
|
||||||
|
"--format".to_string(),
|
||||||
|
"{n}".to_string(),
|
||||||
|
"--db".to_string(),
|
||||||
|
"tmdb".to_string(),
|
||||||
|
file.to_string_lossy().to_string(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(code, 2);
|
||||||
|
assert!(file.exists(), "should fail before touching any file");
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_fails_cleanly_when_db_omdb_is_requested_without_a_saved_key() {
|
||||||
|
// Mirrors `run_fails_cleanly_when_db_tmdb_is_requested_without_a_saved_token`
|
||||||
|
// above, for the OMDb credential instead.
|
||||||
|
if settings::cli_omdb_api_key().is_some() {
|
||||||
|
eprintln!("skipping: a real OMDb API key is saved in this environment's config dir");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = temp_dir();
|
||||||
|
let file = dir.join("Breaking.Bad.S05E14.mkv");
|
||||||
|
std::fs::write(&file, b"hello").unwrap();
|
||||||
|
|
||||||
|
let code = run(vec![
|
||||||
|
"-rename".to_string(),
|
||||||
|
"--format".to_string(),
|
||||||
|
"{n}".to_string(),
|
||||||
|
"--db".to_string(),
|
||||||
|
"omdb".to_string(),
|
||||||
|
file.to_string_lossy().to_string(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(code, 2);
|
||||||
|
assert!(file.exists(), "should fail before touching any file");
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
//! The Tauri-facing side of the Episodes panel: search a series by name and browse one
|
||||||
|
//! season's full episode list, independent of any loaded local files. Unlike
|
||||||
|
//! Subtitles (`subtitles.rs`), this needs no credentials of its own - it reuses the
|
||||||
|
//! exact same `AnyTvClient`/`fetch_season` machinery "Fetch Data" already built (see
|
||||||
|
//! `metadata.rs`), via the shared `metadata::build_tv_client` helper, so this panel
|
||||||
|
//! automatically respects whichever TV source (TMDB/TVmaze/OMDb) is already configured
|
||||||
|
//! in Settings rather than needing its own.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
|
use crate::metadata::build_tv_client;
|
||||||
|
|
||||||
|
/// One episode row, as shown in the Episodes panel's list.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EpisodeRow {
|
||||||
|
pub episode_number: i32,
|
||||||
|
pub title: String,
|
||||||
|
pub air_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "status", rename_all = "camelCase")]
|
||||||
|
pub enum EpisodeListOutcome {
|
||||||
|
Found {
|
||||||
|
series_name: String,
|
||||||
|
episodes: Vec<EpisodeRow>,
|
||||||
|
},
|
||||||
|
Failed {
|
||||||
|
error: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Searches for `series_query` against the currently configured TV source and returns
|
||||||
|
/// every episode of `season`. A season with a valid series match but zero episodes
|
||||||
|
/// (e.g. a season number past the end of the show) still comes back `Found` with an
|
||||||
|
/// empty list - it's a normal "nothing here" result, not a failure - so the frontend
|
||||||
|
/// can tell "found the show, this season is just empty" apart from "couldn't find the
|
||||||
|
/// show at all" (which `fetch_season` reports as an `Err` and this maps to `Failed`).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn browse_episodes(app: AppHandle, series_query: String, season: i32) -> EpisodeListOutcome {
|
||||||
|
let trimmed = series_query.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return EpisodeListOutcome::Failed {
|
||||||
|
error: "Enter a series name to search for.".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = match build_tv_client(&app) {
|
||||||
|
Ok(client) => client,
|
||||||
|
Err(error) => return EpisodeListOutcome::Failed { error },
|
||||||
|
};
|
||||||
|
|
||||||
|
match client.fetch_season(trimmed, season).await {
|
||||||
|
Ok((series_name, episodes)) => EpisodeListOutcome::Found {
|
||||||
|
series_name,
|
||||||
|
episodes: episodes
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| EpisodeRow {
|
||||||
|
episode_number: e.episode_number,
|
||||||
|
title: e.title,
|
||||||
|
air_date: e.air_date,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
Err(error) => EpisodeListOutcome::Failed { error },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// `browse_episodes` itself needs a running `AppHandle` (for `build_tv_client` to
|
||||||
|
// read settings), which unit tests here have no way to construct - same reason
|
||||||
|
// `subtitles.rs`'s `search_subtitles`/`download_subtitle` commands aren't
|
||||||
|
// unit-tested directly either. It's covered end-to-end (real query text, mocked
|
||||||
|
// Tauri backend) by the Playwright pass instead; what's unit-testable here is the
|
||||||
|
// plain data mapping below.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn episode_row_maps_fields_straight_across() {
|
||||||
|
let row = EpisodeRow {
|
||||||
|
episode_number: 5,
|
||||||
|
title: "Ozymandias".to_string(),
|
||||||
|
air_date: Some("2013-09-15".to_string()),
|
||||||
|
};
|
||||||
|
assert_eq!(row.episode_number, 5);
|
||||||
|
assert_eq!(row.title, "Ozymandias");
|
||||||
|
assert_eq!(row.air_date.as_deref(), Some("2013-09-15"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
//! The Tauri-facing side of rename history/undo: persists the log
|
//! The Tauri-facing side of rename history/undo: persists the log
|
||||||
//! `renamr_core::history` produces to disk (plain JSON in the OS app-config
|
//! `aurora_curator_core::history` produces to disk (plain JSON in the OS app-config
|
||||||
//! directory, alongside `settings.json`) and exposes it to the UI as two commands -
|
//! directory, alongside `settings.json`) and exposes it to the UI as two commands -
|
||||||
//! list the log, and undo one batch from it.
|
//! list the log, and undo one batch from it.
|
||||||
|
|
||||||
use renamr_core::history::{self, HistoryBatch, HistoryEntry, RevertResult};
|
use aurora_curator_core::history::{self, HistoryBatch, HistoryEntry, RevertResult};
|
||||||
use renamr_core::rename::{RenameAction, RenameOutcome, RenameResult};
|
use aurora_curator_core::rename::{RenameAction, RenameOutcome, RenameResult};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
@@ -45,7 +45,7 @@ fn now_millis() -> u64 {
|
|||||||
/// Called right after a rename batch executes (see `execute_rename` in `lib.rs`) -
|
/// Called right after a rename batch executes (see `execute_rename` in `lib.rs`) -
|
||||||
/// records only the entries that were actually renamed/copied/linked, and the action
|
/// records only the entries that were actually renamed/copied/linked, and the action
|
||||||
/// that was used to do it (so `undo_rename_batch` later knows whether to move the
|
/// that was used to do it (so `undo_rename_batch` later knows whether to move the
|
||||||
/// result back or just delete it - see `renamr_core::history::revert_one`); a file that
|
/// result back or just delete it - see `aurora_curator_core::history::revert_one`); a file that
|
||||||
/// was unchanged, skipped, or failed has nothing to undo. Best-effort: if persisting the
|
/// was unchanged, skipped, or failed has nothing to undo. Best-effort: if persisting the
|
||||||
/// history fails for some reason, the operation itself already succeeded and shouldn't
|
/// history fails for some reason, the operation itself already succeeded and shouldn't
|
||||||
/// be reported as failed just because its undo record couldn't be saved, so errors here
|
/// be reported as failed just because its undo record couldn't be saved, so errors here
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
|
mod cli;
|
||||||
|
mod episodes;
|
||||||
mod files;
|
mod files;
|
||||||
mod history;
|
mod history;
|
||||||
|
mod list;
|
||||||
mod metadata;
|
mod metadata;
|
||||||
mod presets;
|
mod presets;
|
||||||
|
mod probe;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
mod sfv;
|
||||||
|
mod subtitles;
|
||||||
|
|
||||||
use renamr_core::format::ExpressionFormat;
|
use aurora_curator_core::format::ExpressionFormat;
|
||||||
use renamr_core::matching::{self, EpisodeGuess, MovieGuess, ReleaseInfo};
|
use aurora_curator_core::matching::{self, EpisodeGuess, MovieGuess, ReleaseInfo};
|
||||||
use renamr_core::rename::{self, ConflictPolicy, RenameAction, RenamePlanItem, RenameResult};
|
use aurora_curator_core::rename::{self, ConflictPolicy, RenameAction, RenamePlanItem, RenameResult};
|
||||||
use renamr_core::string_bindings;
|
use aurora_curator_core::string_bindings;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
|
|
||||||
@@ -42,18 +48,20 @@ fn guess_movie(filename: String) -> Option<MovieGuess> {
|
|||||||
|
|
||||||
/// Guesses release-tag metadata (resolution, source, video/audio codec, release
|
/// Guesses release-tag metadata (resolution, source, video/audio codec, release
|
||||||
/// group) from a filename's own text using local regex patterns only - no network
|
/// group) from a filename's own text using local regex patterns only - no network
|
||||||
/// calls, no real media probing (Renamr never ships an ffprobe/MediaInfo dependency,
|
/// calls, no file I/O. Complements `probe::probe_media_files` below, which reads the
|
||||||
/// so filename tags are the only source for these fields). Independent of
|
/// same kind of resolution/codec data straight from a file's real container header
|
||||||
/// `guess_episode`/`guess_movie`: called alongside whichever of those two applies,
|
/// instead of guessing it from filename text - the Rename panel merges both,
|
||||||
/// since a release name carries (or doesn't carry) these tags regardless of whether
|
/// preferring an explicit filename tag over a probed value when a file has both.
|
||||||
/// it's an episode or a movie.
|
/// Independent of `guess_episode`/`guess_movie`: called alongside whichever of those
|
||||||
|
/// two applies, since a release name carries (or doesn't carry) these tags regardless
|
||||||
|
/// of whether it's an episode or a movie.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn guess_release_info(filename: String) -> Option<ReleaseInfo> {
|
fn guess_release_info(filename: String) -> Option<ReleaseInfo> {
|
||||||
matching::guess_release_info(&filename)
|
matching::guess_release_info(&filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Executes a batch of planned on-disk rename/copy/symlink operations (optionally
|
/// Executes a batch of planned on-disk rename/copy/symlink operations (optionally
|
||||||
/// moving/copying files into subfolders - see `renamr_core::rename`). `action` picks
|
/// moving/copying files into subfolders - see `aurora_curator_core::rename`). `action` picks
|
||||||
/// which of the three happens; `conflict` picks what happens when a computed target
|
/// which of the three happens; `conflict` picks what happens when a computed target
|
||||||
/// already exists (skip it, save alongside it with an auto-numbered name, or replace
|
/// already exists (skip it, save alongside it with an auto-numbered name, or replace
|
||||||
/// it) - the UI is expected to have already shown the user the full old-path/new-path
|
/// it) - the UI is expected to have already shown the user the full old-path/new-path
|
||||||
@@ -79,6 +87,13 @@ async fn execute_rename(
|
|||||||
results
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Entry point for the `-rename` command-line mode - see `cli.rs`'s module doc. Never
|
||||||
|
/// builds a Tauri `App`/`Builder` at all, unlike [`run`] below. Returns the process
|
||||||
|
/// exit code for `main.rs` to pass to `std::process::exit`.
|
||||||
|
pub fn run_cli(args: Vec<String>) -> i32 {
|
||||||
|
cli::run(args)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
@@ -93,6 +108,25 @@ pub fn run() {
|
|||||||
metadata::fetch_movie_metadata,
|
metadata::fetch_movie_metadata,
|
||||||
settings::get_tmdb_token,
|
settings::get_tmdb_token,
|
||||||
settings::save_tmdb_token,
|
settings::save_tmdb_token,
|
||||||
|
settings::get_omdb_api_key,
|
||||||
|
settings::save_omdb_api_key,
|
||||||
|
settings::get_tv_metadata_source,
|
||||||
|
settings::save_tv_metadata_source,
|
||||||
|
settings::get_movie_metadata_source,
|
||||||
|
settings::save_movie_metadata_source,
|
||||||
|
settings::get_opensubtitles_api_key,
|
||||||
|
settings::save_opensubtitles_api_key,
|
||||||
|
settings::get_opensubtitles_username,
|
||||||
|
settings::has_opensubtitles_password,
|
||||||
|
settings::save_opensubtitles_login,
|
||||||
|
subtitles::search_subtitles,
|
||||||
|
subtitles::download_subtitle,
|
||||||
|
episodes::browse_episodes,
|
||||||
|
sfv::compute_checksums,
|
||||||
|
sfv::save_checksum_file,
|
||||||
|
sfv::verify_checksum_file,
|
||||||
|
list::export_text_list,
|
||||||
|
probe::probe_media_files,
|
||||||
history::list_rename_history,
|
history::list_rename_history,
|
||||||
history::undo_rename_batch,
|
history::undo_rename_batch,
|
||||||
presets::list_presets,
|
presets::list_presets,
|
||||||
@@ -103,5 +137,5 @@ pub fn run() {
|
|||||||
files::expand_dropped_paths,
|
files::expand_dropped_paths,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running Renamr");
|
.expect("error while running Aurora Curator");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
//! The Tauri-facing side of the List panel: exports a set of already-rendered text
|
||||||
|
//! lines (one per loaded file) to a plain text file the user picks via a native
|
||||||
|
//! "Save File" dialog. This is the *only* new Tauri surface List needs - matching
|
||||||
|
//! each file against local metadata and rendering a naming-scheme-style expression
|
||||||
|
//! per file both reuse commands that already exist: `guess_episode`/`guess_movie`/
|
||||||
|
//! `guess_release_info` (the same local guessers the Rename panel's "Match" and the
|
||||||
|
//! Filter panel's scan already call) and `render_expression` (the same engine the
|
||||||
|
//! Rename panel's live preview already calls). The frontend builds the final,
|
||||||
|
//! newline-joined text; this module's only job is writing it to a file the user
|
||||||
|
//! chooses, the same async native-dialog pattern `sfv::save_checksum_file` uses.
|
||||||
|
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
/// The `.txt`-suffixed file name to default the Save dialog to.
|
||||||
|
fn default_export_filename(default_name: &str) -> String {
|
||||||
|
format!("{default_name}.txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a native "Save File" dialog defaulted to `<default_name>.txt`, and if the
|
||||||
|
/// user doesn't cancel, writes `content` there verbatim (already newline-joined by
|
||||||
|
/// the frontend, one rendered line per loaded file). Returns the saved path, or
|
||||||
|
/// `None` if the user cancelled the dialog (not an error) - same shape as
|
||||||
|
/// `sfv::save_checksum_file`.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn export_text_list(app: AppHandle, default_name: String, content: String) -> Result<Option<String>, String> {
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
app.dialog()
|
||||||
|
.file()
|
||||||
|
.set_file_name(default_export_filename(&default_name))
|
||||||
|
.save_file(move |result| {
|
||||||
|
let _ = tx.send(result);
|
||||||
|
});
|
||||||
|
let chosen = rx.await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let Some(file_path) = chosen else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let path = file_path.into_path().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn_blocking({
|
||||||
|
let path = path.clone();
|
||||||
|
move || std::fs::write(&path, content)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(Some(path.to_string_lossy().to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_export_filename_appends_txt_extension() {
|
||||||
|
assert_eq!(default_export_filename("my-list"), "my-list.txt");
|
||||||
|
assert_eq!(default_export_filename(""), ".txt");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,5 +2,16 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
renamr_lib::run();
|
// `-rename`/`-h`/`--help` on the command line means this is a scripted CLI
|
||||||
|
// invocation, not a normal double-click GUI launch - see
|
||||||
|
// `aurora_curator_core::cli`'s module doc. Checked with the exact same predicate
|
||||||
|
// `cli::parse_args` itself uses internally, so this can never drift out of sync
|
||||||
|
// with what actually triggers CLI mode. No args at all (the normal launch case)
|
||||||
|
// falls straight through to the unchanged GUI path below.
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
if aurora_curator_core::cli::looks_like_cli_invocation(&args) {
|
||||||
|
std::process::exit(aurora_curator_lib::run_cli(args));
|
||||||
|
}
|
||||||
|
|
||||||
|
aurora_curator_lib::run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
//! The Tauri-facing side of "Fetch Data": takes the local season/episode guesses the
|
//! The Tauri-facing side of "Fetch Data": takes the local season/episode guesses the
|
||||||
//! UI already has (from `guess_episode`/Match) and resolves them against real TMDB
|
//! UI already has (from `guess_episode`/Match) and resolves them against real online
|
||||||
//! metadata. Deliberately layered on top of local matching rather than replacing it -
|
//! metadata. Deliberately layered on top of local matching rather than replacing it -
|
||||||
//! Fetch Data needs a series name and season/episode to search with, which is exactly
|
//! Fetch Data needs a series name and season/episode to search with, which is exactly
|
||||||
//! what Match already produces for free, offline.
|
//! what Match already produces for free, offline.
|
||||||
|
//!
|
||||||
|
//! TV lookups can go against TMDB, TVmaze, or OMDb - whichever `settings::tv_metadata_
|
||||||
|
//! source` currently has stored (see `AnyTvClient` below), defaulting to TMDB. Movie
|
||||||
|
//! lookups can go against TMDB or OMDb - whichever `settings::movie_metadata_source`
|
||||||
|
//! currently has stored (see `AnyMovieClient` below), defaulting to TMDB; TVmaze has no
|
||||||
|
//! movie catalog, so it's never an option for the movie half of Fetch Data.
|
||||||
|
|
||||||
use renamr_core::web::tmdb::{SeasonEpisode, TmdbClient};
|
use aurora_curator_core::web::omdb::OmdbClient;
|
||||||
|
use aurora_curator_core::web::tmdb::TmdbClient;
|
||||||
|
use aurora_curator_core::web::tvmaze::TvmazeClient;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
|
|
||||||
use crate::settings;
|
use crate::settings::{self, MovieMetadataSource, TvMetadataSource};
|
||||||
|
|
||||||
/// One file's local guess, sent up from the UI to be resolved against TMDB.
|
/// One file's local guess, sent up from the UI to be resolved against TMDB.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@@ -44,34 +52,207 @@ pub struct FetchResult {
|
|||||||
pub outcome: FetchOutcome,
|
pub outcome: FetchOutcome,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves a batch of local guesses against TMDB. Runs off the main thread since it's
|
/// One episode of a resolved season - the shape both TMDB's and TVmaze's own
|
||||||
/// doing real network I/O; not `blocking_*`-style Tauri API, so no deadlock risk like
|
/// per-source episode types get mapped into by `AnyTvClient::fetch_season`, so the
|
||||||
/// the dialog commands had - `spawn` here is just to keep it explicitly off the
|
/// caching/lookup loop in `run_fetch` never needs to know which source produced a
|
||||||
/// command-invocation path some Tauri internals run on.
|
/// result. `pub(crate)` (not just used by the GUI's own `fetch_metadata` command below)
|
||||||
|
/// so `cli.rs`'s `-rename --db` orchestration can reuse the exact same season-caching
|
||||||
|
/// lookup rather than duplicating it - see that module's doc comment.
|
||||||
|
pub(crate) struct LocalEpisode {
|
||||||
|
pub(crate) episode_number: i32,
|
||||||
|
pub(crate) title: String,
|
||||||
|
pub(crate) air_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) type SeasonLookup = Result<(String, Vec<LocalEpisode>), String>;
|
||||||
|
|
||||||
|
/// Either TV metadata client, picked once per `fetch_metadata` call (or, for the CLI,
|
||||||
|
/// once per `-rename --db` invocation) based on the chosen source, then reused across
|
||||||
|
/// every item in the batch (so a setting change mid-batch, or a long CLI run, can't
|
||||||
|
/// produce mixed-source results within one Fetch Data click or one `-rename` command).
|
||||||
|
/// `pub(crate)`: see `LocalEpisode`'s doc comment above.
|
||||||
|
pub(crate) enum AnyTvClient {
|
||||||
|
Tmdb(TmdbClient),
|
||||||
|
Tvmaze(TvmazeClient),
|
||||||
|
Omdb(OmdbClient),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnyTvClient {
|
||||||
|
/// The human-readable source name, used only in error messages so a "no match"
|
||||||
|
/// failure tells the user which source actually came up empty.
|
||||||
|
fn source_name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
AnyTvClient::Tmdb(_) => "TMDB",
|
||||||
|
AnyTvClient::Tvmaze(_) => "TVmaze",
|
||||||
|
AnyTvClient::Omdb(_) => "OMDb",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn fetch_season(&self, series_query: &str, season: i32) -> SeasonLookup {
|
||||||
|
match self {
|
||||||
|
AnyTvClient::Tmdb(client) => {
|
||||||
|
let best = client
|
||||||
|
.search_tv_best_match(series_query)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.ok_or_else(|| format!("no {} match for \"{series_query}\"", self.source_name()))?;
|
||||||
|
let episodes = client.season_episodes(best.id, season).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok((
|
||||||
|
best.name,
|
||||||
|
episodes
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| LocalEpisode {
|
||||||
|
episode_number: e.episode_number,
|
||||||
|
title: e.title,
|
||||||
|
air_date: e.air_date,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
AnyTvClient::Tvmaze(client) => {
|
||||||
|
let best = client
|
||||||
|
.search_tv_best_match(series_query)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.ok_or_else(|| format!("no {} match for \"{series_query}\"", self.source_name()))?;
|
||||||
|
let episodes = client.season_episodes(best.id, season).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok((
|
||||||
|
best.name,
|
||||||
|
episodes
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| LocalEpisode {
|
||||||
|
episode_number: e.episode_number,
|
||||||
|
title: e.title,
|
||||||
|
air_date: e.air_date,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
AnyTvClient::Omdb(client) => {
|
||||||
|
let best = client
|
||||||
|
.search_tv_best_match(series_query)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.ok_or_else(|| format!("no {} match for \"{series_query}\"", self.source_name()))?;
|
||||||
|
let episodes = client
|
||||||
|
.season_episodes(&best.imdb_id, season)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok((
|
||||||
|
best.name,
|
||||||
|
episodes
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| LocalEpisode {
|
||||||
|
episode_number: e.episode_number,
|
||||||
|
title: e.title,
|
||||||
|
air_date: e.air_date,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a movie lookup found - `NotFound` is a normal, expected outcome (nothing
|
||||||
|
/// matched) and is kept distinct from `Err` (a real network/API failure) the same way
|
||||||
|
/// `TmdbError::NotFound`/`OmdbError::NotFound` are - `Err`'s `String` here is that
|
||||||
|
/// underlying error's `Display` text, already source-agnostic by the time it reaches a
|
||||||
|
/// caller.
|
||||||
|
pub(crate) enum MovieLookup {
|
||||||
|
Found { title: String, release_date: Option<String> },
|
||||||
|
NotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Either movie metadata client, picked once per `fetch_movie_metadata` call (or, for
|
||||||
|
/// the CLI, once per `-rename --db` invocation) based on the chosen source - mirrors
|
||||||
|
/// `AnyTvClient` above, but for the movie half of Fetch Data. `pub(crate)`: see
|
||||||
|
/// `LocalEpisode`'s doc comment above.
|
||||||
|
pub(crate) enum AnyMovieClient {
|
||||||
|
Tmdb(TmdbClient),
|
||||||
|
Omdb(OmdbClient),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnyMovieClient {
|
||||||
|
pub(crate) async fn lookup_movie(&self, title_query: &str, year: Option<i32>) -> Result<MovieLookup, String> {
|
||||||
|
match self {
|
||||||
|
AnyMovieClient::Tmdb(client) => match client.lookup_movie(title_query, year).await {
|
||||||
|
Ok(info) => Ok(MovieLookup::Found {
|
||||||
|
title: info.title,
|
||||||
|
release_date: info.release_date,
|
||||||
|
}),
|
||||||
|
Err(aurora_curator_core::web::tmdb::TmdbError::NotFound(_)) => Ok(MovieLookup::NotFound),
|
||||||
|
Err(error) => Err(error.to_string()),
|
||||||
|
},
|
||||||
|
AnyMovieClient::Omdb(client) => match client.lookup_movie(title_query, year).await {
|
||||||
|
Ok(info) => Ok(MovieLookup::Found {
|
||||||
|
title: info.title,
|
||||||
|
release_date: info.release_date,
|
||||||
|
}),
|
||||||
|
Err(aurora_curator_core::web::omdb::OmdbError::NotFound(_)) => Ok(MovieLookup::NotFound),
|
||||||
|
Err(error) => Err(error.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the "every item failed the same way" result list a missing-credential guard
|
||||||
|
/// clause returns before any client is even constructed - shared by `fetch_metadata`
|
||||||
|
/// and `fetch_movie_metadata` below so the message and shape stay identical between the
|
||||||
|
/// TV and movie halves of Fetch Data.
|
||||||
|
fn failed_all(items: Vec<FetchQuery>, error: &str) -> Vec<FetchResult> {
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| FetchResult {
|
||||||
|
id: item.id,
|
||||||
|
outcome: FetchOutcome::Failed { error: error.to_string() },
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds whichever TV metadata client `settings::tv_metadata_source` currently has
|
||||||
|
/// picked, reading that source's saved credential (if it needs one). Shared by
|
||||||
|
/// `fetch_metadata` below and, from the separate Episodes-browsing panel,
|
||||||
|
/// `episodes::browse_episodes` - both need "the currently configured TV client" and
|
||||||
|
/// neither should duplicate this match. `pub(crate)`: see `LocalEpisode`'s doc comment.
|
||||||
|
pub(crate) fn build_tv_client(app: &AppHandle) -> Result<AnyTvClient, String> {
|
||||||
|
match settings::tv_metadata_source(app) {
|
||||||
|
TvMetadataSource::Tmdb => {
|
||||||
|
let token = settings::tmdb_token(app)
|
||||||
|
.ok_or_else(|| "No TMDB API token configured — open Settings to add one.".to_string())?;
|
||||||
|
Ok(AnyTvClient::Tmdb(TmdbClient::new(token)))
|
||||||
|
}
|
||||||
|
TvMetadataSource::Tvmaze => Ok(AnyTvClient::Tvmaze(TvmazeClient::new())),
|
||||||
|
TvMetadataSource::Omdb => {
|
||||||
|
let key = settings::omdb_api_key(app)
|
||||||
|
.ok_or_else(|| "No OMDb API key configured — open Settings to add one.".to_string())?;
|
||||||
|
Ok(AnyTvClient::Omdb(OmdbClient::new(key)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a batch of local guesses against real TV metadata. Runs off the main
|
||||||
|
/// thread since it's doing real network I/O; not `blocking_*`-style Tauri API, so no
|
||||||
|
/// deadlock risk like the dialog commands had - `spawn` here is just to keep it
|
||||||
|
/// explicitly off the command-invocation path some Tauri internals run on.
|
||||||
|
///
|
||||||
|
/// TVmaze needs no credential at all, so a batch can run against it with nothing
|
||||||
|
/// configured; TMDB and OMDb each need their own saved credential before a batch
|
||||||
|
/// resolved against them will succeed (independently of whichever source movies are
|
||||||
|
/// using, elsewhere in this file).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn fetch_metadata(app: AppHandle, items: Vec<FetchQuery>) -> Vec<FetchResult> {
|
pub async fn fetch_metadata(app: AppHandle, items: Vec<FetchQuery>) -> Vec<FetchResult> {
|
||||||
let Some(token) = settings::tmdb_token(&app) else {
|
let client = match build_tv_client(&app) {
|
||||||
return items
|
Ok(client) => client,
|
||||||
.into_iter()
|
Err(error) => return failed_all(items, &error),
|
||||||
.map(|item| FetchResult {
|
|
||||||
id: item.id,
|
|
||||||
outcome: FetchOutcome::Failed {
|
|
||||||
error: "No TMDB API token configured — open Settings to add one.".to_string(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
tauri::async_runtime::spawn(run_fetch(token, items))
|
tauri::async_runtime::spawn(run_fetch(client, items))
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
type SeasonLookup = Result<(String, Vec<SeasonEpisode>), String>;
|
async fn run_fetch(client: AnyTvClient, items: Vec<FetchQuery>) -> Vec<FetchResult> {
|
||||||
|
|
||||||
async fn run_fetch(token: String, items: Vec<FetchQuery>) -> Vec<FetchResult> {
|
|
||||||
let client = TmdbClient::new(token);
|
|
||||||
|
|
||||||
// Cache per (series_query, season): a whole loaded season's worth of files only
|
// Cache per (series_query, season): a whole loaded season's worth of files only
|
||||||
// needs one search + one season-episode-list fetch, not one per file.
|
// needs one search + one season-episode-list fetch, not one per file.
|
||||||
let mut season_cache: HashMap<(String, i32), SeasonLookup> = HashMap::new();
|
let mut season_cache: HashMap<(String, i32), SeasonLookup> = HashMap::new();
|
||||||
@@ -81,7 +262,7 @@ async fn run_fetch(token: String, items: Vec<FetchQuery>) -> Vec<FetchResult> {
|
|||||||
let key = (item.series_query.clone(), item.season);
|
let key = (item.series_query.clone(), item.season);
|
||||||
|
|
||||||
if !season_cache.contains_key(&key) {
|
if !season_cache.contains_key(&key) {
|
||||||
let looked_up = fetch_season(&client, &item.series_query, item.season).await;
|
let looked_up = client.fetch_season(&item.series_query, item.season).await;
|
||||||
season_cache.insert(key.clone(), looked_up);
|
season_cache.insert(key.clone(), looked_up);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,19 +284,6 @@ async fn run_fetch(token: String, items: Vec<FetchQuery>) -> Vec<FetchResult> {
|
|||||||
results
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_season(client: &TmdbClient, series_query: &str, season: i32) -> SeasonLookup {
|
|
||||||
let best = client
|
|
||||||
.search_tv_best_match(series_query)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
.ok_or_else(|| format!("no TMDB match for \"{series_query}\""))?;
|
|
||||||
let episodes = client
|
|
||||||
.season_episodes(best.id, season)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
Ok((best.name, episodes))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One file's local movie-title guess, sent up from the UI to be resolved against
|
/// One file's local movie-title guess, sent up from the UI to be resolved against
|
||||||
/// TMDB. Mirrors `FetchQuery`, but for the movie side of "Fetch Data".
|
/// TMDB. Mirrors `FetchQuery`, but for the movie side of "Fetch Data".
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@@ -149,41 +317,53 @@ pub struct MovieFetchResult {
|
|||||||
pub outcome: MovieFetchOutcome,
|
pub outcome: MovieFetchOutcome,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves a batch of local movie-title guesses against TMDB. Mirrors
|
/// Resolves a batch of local movie-title guesses against real movie metadata. Mirrors
|
||||||
/// `fetch_metadata`, but each query is independent (no per-season caching the way TV
|
/// `fetch_metadata`, but each query is independent (no per-season caching the way TV
|
||||||
/// episodes benefit from) since a movie search result already carries everything
|
/// episodes benefit from) since a movie search result already carries everything
|
||||||
/// needed in one request.
|
/// needed in one request (well, one search plus one detail request for OMDb - see
|
||||||
|
/// `omdb::OmdbClient::lookup_movie` - but that's an implementation detail this command
|
||||||
|
/// doesn't need to know about).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn fetch_movie_metadata(app: AppHandle, items: Vec<MovieFetchQuery>) -> Vec<MovieFetchResult> {
|
pub async fn fetch_movie_metadata(app: AppHandle, items: Vec<MovieFetchQuery>) -> Vec<MovieFetchResult> {
|
||||||
let Some(token) = settings::tmdb_token(&app) else {
|
let client = match settings::movie_metadata_source(&app) {
|
||||||
return items
|
MovieMetadataSource::Tmdb => {
|
||||||
.into_iter()
|
let Some(token) = settings::tmdb_token(&app) else {
|
||||||
.map(|item| MovieFetchResult {
|
return failed_all_movies(items, "No TMDB API token configured — open Settings to add one.");
|
||||||
id: item.id,
|
};
|
||||||
outcome: MovieFetchOutcome::Failed {
|
AnyMovieClient::Tmdb(TmdbClient::new(token))
|
||||||
error: "No TMDB API token configured — open Settings to add one.".to_string(),
|
}
|
||||||
},
|
MovieMetadataSource::Omdb => {
|
||||||
})
|
let Some(key) = settings::omdb_api_key(&app) else {
|
||||||
.collect();
|
return failed_all_movies(items, "No OMDb API key configured — open Settings to add one.");
|
||||||
|
};
|
||||||
|
AnyMovieClient::Omdb(OmdbClient::new(key))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
tauri::async_runtime::spawn(run_movie_fetch(token, items))
|
tauri::async_runtime::spawn(run_movie_fetch(client, items))
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_movie_fetch(token: String, items: Vec<MovieFetchQuery>) -> Vec<MovieFetchResult> {
|
/// Same idea as `failed_all` above, for the movie side's own result shape.
|
||||||
let client = TmdbClient::new(token);
|
fn failed_all_movies(items: Vec<MovieFetchQuery>, error: &str) -> Vec<MovieFetchResult> {
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| MovieFetchResult {
|
||||||
|
id: item.id,
|
||||||
|
outcome: MovieFetchOutcome::Failed { error: error.to_string() },
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_movie_fetch(client: AnyMovieClient, items: Vec<MovieFetchQuery>) -> Vec<MovieFetchResult> {
|
||||||
let mut results = Vec::with_capacity(items.len());
|
let mut results = Vec::with_capacity(items.len());
|
||||||
|
|
||||||
for item in items {
|
for item in items {
|
||||||
let outcome = match client.lookup_movie(&item.title_query, item.year).await {
|
let outcome = match client.lookup_movie(&item.title_query, item.year).await {
|
||||||
Ok(info) => MovieFetchOutcome::Found {
|
Ok(MovieLookup::Found { title, release_date }) => MovieFetchOutcome::Found { title, release_date },
|
||||||
title: info.title,
|
Ok(MovieLookup::NotFound) => MovieFetchOutcome::NotFound,
|
||||||
release_date: info.release_date,
|
Err(error) => MovieFetchOutcome::Failed { error },
|
||||||
},
|
|
||||||
Err(renamr_core::web::tmdb::TmdbError::NotFound(_)) => MovieFetchOutcome::NotFound,
|
|
||||||
Err(error) => MovieFetchOutcome::Failed { error: error.to_string() },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
results.push(MovieFetchResult { id: item.id, outcome });
|
results.push(MovieFetchResult { id: item.id, outcome });
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//! The Tauri-facing side of real media-file probing: reads real container metadata
|
||||||
|
//! (resolution, duration, frame rate, codecs) straight from each loaded file's own
|
||||||
|
//! header via `aurora_curator_core::probe`, batched the same way `sfv::compute_checksums`
|
||||||
|
//! batches checksum hashing - one Tauri round trip for the whole loaded set, run off
|
||||||
|
//! the main thread since this is real file I/O (a probe only reads a container's
|
||||||
|
//! header/index boxes, not the whole file, but a movie file can still be tens of GB
|
||||||
|
//! and may live on a slow network share).
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use aurora_curator_core::probe::{self, MediaProbe};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// One file's probe result, paired with the path it came from so the frontend can
|
||||||
|
/// match results back to the loaded items it requested (order isn't otherwise
|
||||||
|
/// guaranteed to be preserved end to end through `spawn_blocking`).
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ProbeOutcome {
|
||||||
|
pub path: String,
|
||||||
|
pub probe: MediaProbe,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probes a batch of loaded files. Never fails per-file - `aurora_curator_core::probe::probe_media_file`
|
||||||
|
/// is always best-effort (see its doc comment), so every path in `paths` gets exactly
|
||||||
|
/// one `ProbeOutcome` back, even a file that isn't a supported container or can't be
|
||||||
|
/// read at all (its `MediaProbe` just comes back mostly empty).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn probe_media_files(paths: Vec<String>) -> Vec<ProbeOutcome> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
paths
|
||||||
|
.into_iter()
|
||||||
|
.map(|path| {
|
||||||
|
let probe = probe::probe_media_file(Path::new(&path));
|
||||||
|
ProbeOutcome { path, probe }
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
@@ -1,20 +1,88 @@
|
|||||||
//! Small persisted app settings - currently just the TMDB API token ("Fetch Data" needs
|
//! Small persisted app settings - the TMDB API token, the OMDb API key, and which TV
|
||||||
//! it; everything else in the app works offline without any settings at all). Stored as
|
//! and movie metadata sources Fetch Data should use ("Fetch Data" is the only thing in
|
||||||
//! plain JSON in Tauri's per-OS app config directory (e.g.
|
//! the app that needs settings at all; everything else works offline). Stored
|
||||||
//! `~/Library/Application Support/com.renamr.app/settings.json` on macOS,
|
//! as plain JSON in the OS's standard per-app config directory, joined with the app's
|
||||||
//! `~/.config/renamr/settings.json` on Linux) - not the original FileBot's config
|
//! bundle identifier - `~/Library/Application Support/com.auroracurator.app/settings.json`
|
||||||
//! location, since that's Java Preferences-backed and OS/JVM-specific; this is Renamr's
|
//! on macOS, `~/.config/com.auroracurator.app/settings.json` on Linux, `%APPDATA%\
|
||||||
//! own, analogous location for its own settings.
|
//! com.auroracurator.app\settings.json` on Windows - not the original FileBot's config
|
||||||
|
//! location, since that's Java Preferences-backed and OS/JVM-specific; this is Aurora
|
||||||
|
//! Curator's own, analogous location for its own settings.
|
||||||
|
//!
|
||||||
|
//! The GUI reaches that directory through a running Tauri `AppHandle`
|
||||||
|
//! (`app.path().app_config_dir()`); the `-rename` CLI mode (`cli.rs`) never builds a
|
||||||
|
//! Tauri app at all, so it needs the exact same directory computed a different way -
|
||||||
|
//! see [`cli_tmdb_token`]/[`cli_omdb_api_key`] below, which replicate Tauri's own
|
||||||
|
//! `dirs::config_dir().join(identifier)` computation directly (confirmed against Tauri
|
||||||
|
//! 2.11's `path/desktop.rs` source) so both code paths are guaranteed to read/write the
|
||||||
|
//! same file. [`APP_CONFIG_IDENTIFIER`] must be kept in sync with `tauri.conf.json`'s
|
||||||
|
//! `identifier` field by hand - there's no way to share a constant with the JSON config.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
/// Must match `tauri.conf.json`'s `identifier` field exactly - see the module doc.
|
||||||
|
const APP_CONFIG_IDENTIFIER: &str = "com.auroracurator.app";
|
||||||
|
|
||||||
|
/// Which online source Fetch Data resolves TV episodes against. TVmaze needs no API
|
||||||
|
/// key at all; TMDB and OMDb each need their own saved credential (see
|
||||||
|
/// [`tmdb_token`]/[`omdb_api_key`]) before a batch resolved against them will succeed.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum TvMetadataSource {
|
||||||
|
Tmdb,
|
||||||
|
Tvmaze,
|
||||||
|
Omdb,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TvMetadataSource {
|
||||||
|
fn default() -> Self {
|
||||||
|
TvMetadataSource::Tmdb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which online source Fetch Data resolves movies against. Unlike TV, there's no
|
||||||
|
/// zero-setup option here (TVmaze has no movie catalog) - both choices need their own
|
||||||
|
/// saved credential.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum MovieMetadataSource {
|
||||||
|
Tmdb,
|
||||||
|
Omdb,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MovieMetadataSource {
|
||||||
|
fn default() -> Self {
|
||||||
|
MovieMetadataSource::Tmdb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
struct StoredSettings {
|
struct StoredSettings {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
tmdb_token: Option<String>,
|
tmdb_token: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
omdb_api_key: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
tv_metadata_source: TvMetadataSource,
|
||||||
|
#[serde(default)]
|
||||||
|
movie_metadata_source: MovieMetadataSource,
|
||||||
|
/// OpenSubtitles "consumer" app key - needed to *search* subtitles at all.
|
||||||
|
#[serde(default)]
|
||||||
|
opensubtitles_api_key: Option<String>,
|
||||||
|
/// The end user's own OpenSubtitles account username - needed, alongside
|
||||||
|
/// `opensubtitles_password`, only to *download* a subtitle (search works without
|
||||||
|
/// either). Not treated as sensitive as the password (an OpenSubtitles username is
|
||||||
|
/// often just a public display name), so it's returned to the frontend as-is like
|
||||||
|
/// every other credential's get command.
|
||||||
|
#[serde(default)]
|
||||||
|
opensubtitles_username: Option<String>,
|
||||||
|
/// The end user's own OpenSubtitles account password. Unlike every other credential
|
||||||
|
/// in this file, this is never sent back to the frontend once saved - see
|
||||||
|
/// [`has_opensubtitles_password`], which only reports whether one is set.
|
||||||
|
#[serde(default)]
|
||||||
|
opensubtitles_password: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
|
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
@@ -47,6 +115,20 @@ pub fn tmdb_token(app: &AppHandle) -> Option<String> {
|
|||||||
load(app).tmdb_token.filter(|t| !t.trim().is_empty())
|
load(app).tmdb_token.filter(|t| !t.trim().is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Same as [`tmdb_token`], but for the `-rename` CLI path, which has no `AppHandle` to
|
||||||
|
/// ask (it never builds a Tauri app at all - see `cli.rs`'s module doc). Resolves the
|
||||||
|
/// config directory itself using the identical `dirs::config_dir().join(identifier)`
|
||||||
|
/// computation Tauri's own `AppHandle::path().app_config_dir()` uses internally, so a
|
||||||
|
/// token saved through the GUI's Fetch Data Settings is found here too. Aurora Curator
|
||||||
|
/// still never accepts an API token as a CLI argument - `--db tmdb` only works once a
|
||||||
|
/// token has been saved through the app itself at least once.
|
||||||
|
pub fn cli_tmdb_token() -> Option<String> {
|
||||||
|
let dir = dirs::config_dir()?.join(APP_CONFIG_IDENTIFIER);
|
||||||
|
let contents = fs::read_to_string(dir.join("settings.json")).ok()?;
|
||||||
|
let settings: StoredSettings = serde_json::from_str(&contents).ok()?;
|
||||||
|
settings.tmdb_token.filter(|t| !t.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_tmdb_token(app: AppHandle) -> Option<String> {
|
pub fn get_tmdb_token(app: AppHandle) -> Option<String> {
|
||||||
tmdb_token(&app)
|
tmdb_token(&app)
|
||||||
@@ -59,3 +141,268 @@ pub fn save_tmdb_token(app: AppHandle, token: String) -> Result<(), String> {
|
|||||||
settings.tmdb_token = if trimmed.is_empty() { None } else { Some(trimmed) };
|
settings.tmdb_token = if trimmed.is_empty() { None } else { Some(trimmed) };
|
||||||
save(&app, &settings)
|
save(&app, &settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the stored OMDb API key, or `None` if nothing (or only blank text) is
|
||||||
|
/// saved. Mirrors [`tmdb_token`] exactly - OMDb calls its credential a "key" rather
|
||||||
|
/// than a "token", but it's the same kind of thing (a plain saved secret, entered once
|
||||||
|
/// through Fetch Data Settings, never handled by anything but the user and this app).
|
||||||
|
pub fn omdb_api_key(app: &AppHandle) -> Option<String> {
|
||||||
|
load(app).omdb_api_key.filter(|t| !t.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same as [`omdb_api_key`], but for the `-rename` CLI path - mirrors [`cli_tmdb_token`]
|
||||||
|
/// exactly, see its doc comment for why this can't just ask an `AppHandle`.
|
||||||
|
pub fn cli_omdb_api_key() -> Option<String> {
|
||||||
|
let dir = dirs::config_dir()?.join(APP_CONFIG_IDENTIFIER);
|
||||||
|
let contents = fs::read_to_string(dir.join("settings.json")).ok()?;
|
||||||
|
let settings: StoredSettings = serde_json::from_str(&contents).ok()?;
|
||||||
|
settings.omdb_api_key.filter(|t| !t.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_omdb_api_key(app: AppHandle) -> Option<String> {
|
||||||
|
omdb_api_key(&app)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_omdb_api_key(app: AppHandle, key: String) -> Result<(), String> {
|
||||||
|
let mut settings = load(&app);
|
||||||
|
let trimmed = key.trim().to_string();
|
||||||
|
settings.omdb_api_key = if trimmed.is_empty() { None } else { Some(trimmed) };
|
||||||
|
save(&app, &settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the stored TV metadata source (defaults to TMDB if nothing's been chosen).
|
||||||
|
pub fn tv_metadata_source(app: &AppHandle) -> TvMetadataSource {
|
||||||
|
load(app).tv_metadata_source
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_tv_metadata_source(app: AppHandle) -> TvMetadataSource {
|
||||||
|
tv_metadata_source(&app)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_tv_metadata_source(app: AppHandle, source: TvMetadataSource) -> Result<(), String> {
|
||||||
|
let mut settings = load(&app);
|
||||||
|
settings.tv_metadata_source = source;
|
||||||
|
save(&app, &settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the stored movie metadata source (defaults to TMDB if nothing's been
|
||||||
|
/// chosen - movies were TMDB-only before OMDb added a second option).
|
||||||
|
pub fn movie_metadata_source(app: &AppHandle) -> MovieMetadataSource {
|
||||||
|
load(app).movie_metadata_source
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_movie_metadata_source(app: AppHandle) -> MovieMetadataSource {
|
||||||
|
movie_metadata_source(&app)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_movie_metadata_source(app: AppHandle, source: MovieMetadataSource) -> Result<(), String> {
|
||||||
|
let mut settings = load(&app);
|
||||||
|
settings.movie_metadata_source = source;
|
||||||
|
save(&app, &settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the stored OpenSubtitles app key, or `None` if nothing (or only blank text)
|
||||||
|
/// is saved. Needed for search; download additionally needs
|
||||||
|
/// [`opensubtitles_username`]/[`opensubtitles_password_is_set`].
|
||||||
|
pub fn opensubtitles_api_key(app: &AppHandle) -> Option<String> {
|
||||||
|
load(app).opensubtitles_api_key.filter(|t| !t.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the stored OpenSubtitles account username, or `None` if unset.
|
||||||
|
pub fn opensubtitles_username(app: &AppHandle) -> Option<String> {
|
||||||
|
load(app).opensubtitles_username.filter(|t| !t.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the stored OpenSubtitles account password, or `None` if unset. Kept
|
||||||
|
/// `pub(crate)` rather than `pub` - unlike every other credential, this value must
|
||||||
|
/// never cross the Tauri command boundary back to the frontend (see
|
||||||
|
/// [`has_opensubtitles_password`]), so only this crate's own subtitle-download code
|
||||||
|
/// should ever read it.
|
||||||
|
pub(crate) fn opensubtitles_password(app: &AppHandle) -> Option<String> {
|
||||||
|
load(app).opensubtitles_password.filter(|t| !t.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_opensubtitles_api_key(app: AppHandle) -> Option<String> {
|
||||||
|
opensubtitles_api_key(&app)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_opensubtitles_api_key(app: AppHandle, key: String) -> Result<(), String> {
|
||||||
|
let mut settings = load(&app);
|
||||||
|
let trimmed = key.trim().to_string();
|
||||||
|
settings.opensubtitles_api_key = if trimmed.is_empty() { None } else { Some(trimmed) };
|
||||||
|
save(&app, &settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_opensubtitles_username(app: AppHandle) -> Option<String> {
|
||||||
|
opensubtitles_username(&app)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports only *whether* a password is saved, never the password itself - the one
|
||||||
|
/// deliberate exception to this module's usual "get returns the real saved value"
|
||||||
|
/// pattern, since a real account password is more sensitive than an API token/key.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn has_opensubtitles_password(app: AppHandle) -> bool {
|
||||||
|
opensubtitles_password(&app).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure merge logic for [`save_opensubtitles_login`], split out so it's directly unit
|
||||||
|
/// testable without an `AppHandle`. A blank field keeps whatever was saved for it
|
||||||
|
/// before (so updating just the password doesn't force retyping the username, and vice
|
||||||
|
/// versa) - only when BOTH are blank does this clear the login entirely.
|
||||||
|
fn merge_login_update(
|
||||||
|
current_username: Option<String>,
|
||||||
|
current_password: Option<String>,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> (Option<String>, Option<String>) {
|
||||||
|
if username.is_empty() && password.is_empty() {
|
||||||
|
return (None, None);
|
||||||
|
}
|
||||||
|
let new_username = {
|
||||||
|
let trimmed = username.trim().to_string();
|
||||||
|
if trimmed.is_empty() { current_username } else { Some(trimmed) }
|
||||||
|
};
|
||||||
|
let new_password = {
|
||||||
|
let trimmed = password.trim().to_string();
|
||||||
|
if trimmed.is_empty() { current_password } else { Some(trimmed) }
|
||||||
|
};
|
||||||
|
(new_username, new_password)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the OpenSubtitles login (username + password) as one unit, since downloading
|
||||||
|
/// needs both together. See [`merge_login_update`] for the exact keep-vs-clear rules.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_opensubtitles_login(app: AppHandle, username: String, password: String) -> Result<(), String> {
|
||||||
|
let mut settings = load(&app);
|
||||||
|
let (new_username, new_password) = merge_login_update(
|
||||||
|
settings.opensubtitles_username.take(),
|
||||||
|
settings.opensubtitles_password.take(),
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
settings.opensubtitles_username = new_username;
|
||||||
|
settings.opensubtitles_password = new_password;
|
||||||
|
save(&app, &settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tv_metadata_source_defaults_to_tmdb() {
|
||||||
|
assert_eq!(TvMetadataSource::default(), TvMetadataSource::Tmdb);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tv_metadata_source_serializes_as_lowercase_camel_case() {
|
||||||
|
// The frontend sends/receives this as a plain string ("tmdb"/"tvmaze"/"omdb") -
|
||||||
|
// pin the exact serde output so a future refactor can't silently change the
|
||||||
|
// wire format out from under the TS side without a test catching it.
|
||||||
|
assert_eq!(serde_json::to_string(&TvMetadataSource::Tmdb).unwrap(), "\"tmdb\"");
|
||||||
|
assert_eq!(serde_json::to_string(&TvMetadataSource::Tvmaze).unwrap(), "\"tvmaze\"");
|
||||||
|
assert_eq!(serde_json::to_string(&TvMetadataSource::Omdb).unwrap(), "\"omdb\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn movie_metadata_source_defaults_to_tmdb_and_serializes_as_lowercase() {
|
||||||
|
assert_eq!(MovieMetadataSource::default(), MovieMetadataSource::Tmdb);
|
||||||
|
assert_eq!(serde_json::to_string(&MovieMetadataSource::Tmdb).unwrap(), "\"tmdb\"");
|
||||||
|
assert_eq!(serde_json::to_string(&MovieMetadataSource::Omdb).unwrap(), "\"omdb\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_config_identifier_matches_tauri_conf_json() {
|
||||||
|
// `cli_tmdb_token()` recomputes the GUI's settings-file path by hand (no
|
||||||
|
// `AppHandle` available in `-rename` mode), so this constant has to be kept in
|
||||||
|
// sync with `tauri.conf.json`'s `identifier` field manually - see the module
|
||||||
|
// doc. There's no way to share a single source of truth between a Rust const
|
||||||
|
// and the JSON config, so this test at least pins the value we're relying on:
|
||||||
|
// a change to `tauri.conf.json` without a matching change here would silently
|
||||||
|
// make the CLI look in the wrong directory for a token the GUI already saved.
|
||||||
|
let conf = include_str!("../tauri.conf.json");
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(conf).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
parsed["identifier"].as_str(),
|
||||||
|
Some(APP_CONFIG_IDENTIFIER),
|
||||||
|
"APP_CONFIG_IDENTIFIER must match tauri.conf.json's identifier field"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stored_settings_without_a_tv_metadata_source_field_still_deserializes() {
|
||||||
|
// Settings written before this field existed (just `{"tmdbToken": "..."}` or
|
||||||
|
// similar) must still load cleanly, defaulting to Tmdb - same
|
||||||
|
// schema-evolution pattern used for `HistoryBatch.action` (see project memory).
|
||||||
|
let old_json = r#"{"tmdb_token":"abc123"}"#;
|
||||||
|
let parsed: StoredSettings = serde_json::from_str(old_json).unwrap();
|
||||||
|
assert_eq!(parsed.tv_metadata_source, TvMetadataSource::Tmdb);
|
||||||
|
assert_eq!(parsed.tmdb_token, Some("abc123".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stored_settings_without_omdb_fields_still_deserializes() {
|
||||||
|
// Same schema-evolution guarantee as the TV-metadata-source test above, for the
|
||||||
|
// two fields OMDb support added: an old settings.json with neither
|
||||||
|
// `omdb_api_key` nor `movie_metadata_source` present must still load cleanly.
|
||||||
|
let old_json = r#"{"tmdb_token":"abc123","tv_metadata_source":"tvmaze"}"#;
|
||||||
|
let parsed: StoredSettings = serde_json::from_str(old_json).unwrap();
|
||||||
|
assert_eq!(parsed.omdb_api_key, None);
|
||||||
|
assert_eq!(parsed.movie_metadata_source, MovieMetadataSource::Tmdb);
|
||||||
|
assert_eq!(parsed.tv_metadata_source, TvMetadataSource::Tvmaze);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stored_settings_without_opensubtitles_fields_still_deserializes() {
|
||||||
|
// Same schema-evolution guarantee as the OMDb test above, for the three fields
|
||||||
|
// the OpenSubtitles subtitle feature added.
|
||||||
|
let old_json = r#"{"tmdb_token":"abc123","omdb_api_key":"def456"}"#;
|
||||||
|
let parsed: StoredSettings = serde_json::from_str(old_json).unwrap();
|
||||||
|
assert_eq!(parsed.opensubtitles_api_key, None);
|
||||||
|
assert_eq!(parsed.opensubtitles_username, None);
|
||||||
|
assert_eq!(parsed.opensubtitles_password, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_login_update_keeps_the_other_field_when_one_is_blank() {
|
||||||
|
// Updating just the password shouldn't silently clear a previously-saved
|
||||||
|
// username, and vice versa.
|
||||||
|
let (username, password) = merge_login_update(
|
||||||
|
Some("old-name".to_string()),
|
||||||
|
Some("old-pass".to_string()),
|
||||||
|
String::new(),
|
||||||
|
"new-pass".to_string(),
|
||||||
|
);
|
||||||
|
assert_eq!(username, Some("old-name".to_string()));
|
||||||
|
assert_eq!(password, Some("new-pass".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_login_update_clears_both_only_when_both_are_blank() {
|
||||||
|
let (username, password) = merge_login_update(
|
||||||
|
Some("old-name".to_string()),
|
||||||
|
Some("old-pass".to_string()),
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
);
|
||||||
|
assert_eq!(username, None);
|
||||||
|
assert_eq!(password, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_login_update_trims_whitespace() {
|
||||||
|
let (username, password) =
|
||||||
|
merge_login_update(None, None, " someone ".to_string(), " hunter2 ".to_string());
|
||||||
|
assert_eq!(username, Some("someone".to_string()));
|
||||||
|
assert_eq!(password, Some("hunter2".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
//! The Tauri-facing side of the SFV panel: compute checksums for a set of loaded
|
||||||
|
//! files, save them as a sidecar checksum file (`.sfv`/`.md5`/`.sha1`), and verify a
|
||||||
|
//! set of files against an existing checksum file. Wraps `aurora_curator_core::sfv`,
|
||||||
|
//! which does all the actual algorithm/format work - this module is just Tauri
|
||||||
|
//! commands, native file dialogs, and the async/off-main-thread plumbing. Fully local:
|
||||||
|
//! unlike every other panel added this session, SFV needs no network access and no
|
||||||
|
//! credentials at all.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use aurora_curator_core::sfv::{self, ChecksumEntry, ChecksumKind, VerifyResult};
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
/// One computed checksum, or the error hashing that particular file hit (a file
|
||||||
|
/// disappearing between being loaded and being hashed, a permissions problem, etc) -
|
||||||
|
/// kept per-file so one bad file doesn't abort the whole batch.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ChecksumOutcome {
|
||||||
|
pub path: String,
|
||||||
|
pub name: String,
|
||||||
|
pub checksum: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_name_of(path: &str) -> String {
|
||||||
|
Path::new(path)
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| path.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes a checksum for every path in `paths`, using the algorithm `kind` selects.
|
||||||
|
/// Runs the whole batch on a blocking thread (real file I/O, potentially large video
|
||||||
|
/// files) rather than the async command-invocation path.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn compute_checksums(paths: Vec<String>, kind: ChecksumKind) -> Vec<ChecksumOutcome> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
paths
|
||||||
|
.into_iter()
|
||||||
|
.map(|path| {
|
||||||
|
let name = file_name_of(&path);
|
||||||
|
match sfv::compute_checksum(Path::new(&path), kind) {
|
||||||
|
Ok(checksum) => ChecksumOutcome { path, name, checksum: Some(checksum), error: None },
|
||||||
|
Err(err) => ChecksumOutcome { path, name, checksum: None, error: Some(err.to_string()) },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row the frontend already has (a computed checksum for a loaded file) - the
|
||||||
|
/// input shape `save_checksum_file` builds a sidecar file's text content from.
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ChecksumFileRow {
|
||||||
|
pub name: String,
|
||||||
|
pub checksum: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a native "Save File" dialog defaulted to `<default_name>.<kind's extension>`,
|
||||||
|
/// and if the user doesn't cancel, writes the built checksum-file text there. Returns
|
||||||
|
/// the saved path, or `None` if the user cancelled the dialog (not an error).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_checksum_file(app: AppHandle, default_name: String, rows: Vec<ChecksumFileRow>, kind: ChecksumKind) -> Result<Option<String>, String> {
|
||||||
|
let entries: Vec<ChecksumEntry> = rows.into_iter().map(|r| ChecksumEntry { file_name: r.name, checksum: r.checksum }).collect();
|
||||||
|
let content = sfv::build_checksum_file(&entries, kind);
|
||||||
|
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
app.dialog()
|
||||||
|
.file()
|
||||||
|
.set_file_name(format!("{default_name}.{}", kind.extension()))
|
||||||
|
.save_file(move |result| {
|
||||||
|
let _ = tx.send(result);
|
||||||
|
});
|
||||||
|
let chosen = rx.await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let Some(file_path) = chosen else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let path = file_path.into_path().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn_blocking({
|
||||||
|
let path = path.clone();
|
||||||
|
move || std::fs::write(&path, content)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(Some(path.to_string_lossy().to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What verifying one checksum file produced: the file's own directory (every entry is
|
||||||
|
/// resolved relative to it) and the per-entry OK/mismatch/missing results.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct VerifyOutcome {
|
||||||
|
pub base_dir: String,
|
||||||
|
pub results: Vec<VerifyResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads and parses `checksum_file_path` (a `.sfv`/`.md5`/`.sha1` file the user picked)
|
||||||
|
/// and verifies every entry it lists against the real files on disk, resolved relative
|
||||||
|
/// to the checksum file's own parent directory.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn verify_checksum_file(checksum_file_path: String, kind: ChecksumKind) -> Result<VerifyOutcome, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let path = PathBuf::from(&checksum_file_path);
|
||||||
|
let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||||
|
let entries = sfv::parse_checksum_file(&content, kind);
|
||||||
|
let base_dir = path.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."));
|
||||||
|
let results = sfv::verify_entries(&entries, &base_dir, kind);
|
||||||
|
Ok(VerifyOutcome { base_dir: base_dir.to_string_lossy().to_string(), results })
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_name_of_strips_the_directory_portion() {
|
||||||
|
assert_eq!(file_name_of("/a/b/c.mkv"), "c.mkv");
|
||||||
|
assert_eq!(file_name_of("just-a-name.mkv"), "just-a-name.mkv");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
//! The Tauri-facing side of subtitle search/download, via OpenSubtitles
|
||||||
|
//! (`aurora_curator_core::web::opensubtitles`) - the app's first web feature that isn't
|
||||||
|
//! a title/air-date metadata lookup. Search only needs the OpenSubtitles app key saved
|
||||||
|
//! in Fetch Data Settings; download additionally needs the user's own OpenSubtitles
|
||||||
|
//! account login (also saved there, kept separately from the app key - see
|
||||||
|
//! `settings.rs`'s `opensubtitles_*` functions) since downloads are quota-limited per
|
||||||
|
//! account, not per app.
|
||||||
|
|
||||||
|
use aurora_curator_core::web::opensubtitles::{OpensubtitlesClient, OpensubtitlesError, SearchParams, SubtitleResult};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::path::Path;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
|
use crate::settings;
|
||||||
|
|
||||||
|
fn describe_error(err: OpensubtitlesError) -> String {
|
||||||
|
match err {
|
||||||
|
OpensubtitlesError::InvalidCredentials => {
|
||||||
|
"OpenSubtitles login failed - check the username and password in Fetch Data Settings.".to_string()
|
||||||
|
}
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Searches OpenSubtitles for subtitles matching a TV episode or movie. Only needs the
|
||||||
|
/// app-level API key - no account login required (unlike `download_subtitle`).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn search_subtitles(
|
||||||
|
app: AppHandle,
|
||||||
|
query: String,
|
||||||
|
season: Option<u32>,
|
||||||
|
episode: Option<u32>,
|
||||||
|
year: Option<u32>,
|
||||||
|
language: String,
|
||||||
|
) -> Result<Vec<SubtitleResult>, String> {
|
||||||
|
let api_key = settings::opensubtitles_api_key(&app)
|
||||||
|
.ok_or_else(|| "Add a free OpenSubtitles API key in Fetch Data Settings first.".to_string())?;
|
||||||
|
|
||||||
|
let client = OpensubtitlesClient::new(api_key);
|
||||||
|
let language = if language.trim().is_empty() {
|
||||||
|
"en".to_string()
|
||||||
|
} else {
|
||||||
|
language
|
||||||
|
};
|
||||||
|
let params = SearchParams {
|
||||||
|
query,
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
year,
|
||||||
|
language,
|
||||||
|
};
|
||||||
|
client.search(¶ms).await.map_err(describe_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SubtitleDownloadOutcome {
|
||||||
|
pub saved_path: String,
|
||||||
|
pub remaining_downloads: i64,
|
||||||
|
pub allowed_downloads: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logs in with the user's saved OpenSubtitles account, downloads one subtitle by its
|
||||||
|
/// search-result `file_id`, and writes it to disk right next to `video_path`. Requires
|
||||||
|
/// all three OpenSubtitles credentials (API key + username + password) to already be
|
||||||
|
/// saved through Fetch Data Settings - Aurora Curator never accepts them as arguments
|
||||||
|
/// from anywhere but that settings form, same standing rule as every other credential.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn download_subtitle(
|
||||||
|
app: AppHandle,
|
||||||
|
file_id: u64,
|
||||||
|
video_path: String,
|
||||||
|
language: String,
|
||||||
|
) -> Result<SubtitleDownloadOutcome, String> {
|
||||||
|
let api_key = settings::opensubtitles_api_key(&app)
|
||||||
|
.ok_or_else(|| "Add a free OpenSubtitles API key in Fetch Data Settings first.".to_string())?;
|
||||||
|
let username = settings::opensubtitles_username(&app).ok_or_else(|| {
|
||||||
|
"Add your OpenSubtitles account login in Fetch Data Settings first - downloading needs it, even though searching doesn't.".to_string()
|
||||||
|
})?;
|
||||||
|
let password = settings::opensubtitles_password(&app).ok_or_else(|| {
|
||||||
|
"Add your OpenSubtitles account login in Fetch Data Settings first - downloading needs it, even though searching doesn't.".to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let client = OpensubtitlesClient::new(api_key);
|
||||||
|
let token = client.login(&username, &password).await.map_err(describe_error)?;
|
||||||
|
let download = client.download(&token, file_id).await.map_err(describe_error)?;
|
||||||
|
let text = client.fetch_subtitle_text(&download.link).await.map_err(describe_error)?;
|
||||||
|
|
||||||
|
let saved_path = subtitle_path_for(&video_path, &language)?;
|
||||||
|
std::fs::write(&saved_path, text).map_err(|e| format!("couldn't write {saved_path}: {e}"))?;
|
||||||
|
|
||||||
|
Ok(SubtitleDownloadOutcome {
|
||||||
|
saved_path,
|
||||||
|
remaining_downloads: download.remaining,
|
||||||
|
allowed_downloads: download.allowed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes where a downloaded subtitle should be saved: right next to the video file,
|
||||||
|
/// same base name, with the language code inserted before a `.srt` extension (e.g.
|
||||||
|
/// `Show.S01E01.mkv` -> `Show.S01E01.en.srt`) - the convention most media players (and
|
||||||
|
/// FileBot itself) look for automatically, so no separate "subtitles" folder or naming
|
||||||
|
/// scheme is needed for this to just work.
|
||||||
|
fn subtitle_path_for(video_path: &str, language: &str) -> Result<String, String> {
|
||||||
|
let path = Path::new(video_path);
|
||||||
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| format!("{video_path} has no parent directory"))?;
|
||||||
|
let stem = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.ok_or_else(|| format!("{video_path} has no filename"))?;
|
||||||
|
let lang = if language.trim().is_empty() { "en" } else { language.trim() };
|
||||||
|
Ok(parent.join(format!("{stem}.{lang}.srt")).to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subtitle_path_sits_next_to_the_video_with_the_language_code_inserted() {
|
||||||
|
let path = subtitle_path_for("/movies/Inception (2010)/Inception.2010.1080p.mkv", "en").unwrap();
|
||||||
|
assert_eq!(path, "/movies/Inception (2010)/Inception.2010.1080p.en.srt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subtitle_path_defaults_to_english_when_no_language_is_given() {
|
||||||
|
let path = subtitle_path_for("/tv/Show/Show.S01E01.mkv", "").unwrap();
|
||||||
|
assert_eq!(path, "/tv/Show/Show.S01E01.en.srt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subtitle_path_trims_whitespace_from_the_language_code() {
|
||||||
|
let path = subtitle_path_for("/tv/Show/Show.S01E01.mkv", " fr ").unwrap();
|
||||||
|
assert_eq!(path, "/tv/Show/Show.S01E01.fr.srt");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Renamr",
|
"productName": "Aurora Curator",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "com.renamr.app",
|
"identifier": "com.auroracurator.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
"beforeBuildCommand": "npm run build",
|
"beforeBuildCommand": "npm run build",
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "Renamr",
|
"title": "Aurora Curator",
|
||||||
"width": 1100,
|
"width": 1100,
|
||||||
"height": 720,
|
"height": 720,
|
||||||
"minWidth": 720,
|
"minWidth": 720,
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { useState } from "react";
|
|||||||
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";
|
||||||
|
import EpisodesPanel from "./components/EpisodesPanel";
|
||||||
|
import SubtitlesPanel from "./components/SubtitlesPanel";
|
||||||
|
import SfvPanel from "./components/SfvPanel";
|
||||||
|
import FilterPanel from "./components/FilterPanel";
|
||||||
|
import ListPanel from "./components/ListPanel";
|
||||||
import PanelStub from "./components/PanelStub";
|
import PanelStub from "./components/PanelStub";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -14,7 +19,21 @@ export default function App() {
|
|||||||
<div className="main-column">
|
<div className="main-column">
|
||||||
<Header title={current.name} />
|
<Header title={current.name} />
|
||||||
<div className="main-content">
|
<div className="main-content">
|
||||||
{selected === "rename" ? <RenamePanel /> : <PanelStub name={current.name} />}
|
{selected === "rename" ? (
|
||||||
|
<RenamePanel />
|
||||||
|
) : selected === "episodes" ? (
|
||||||
|
<EpisodesPanel />
|
||||||
|
) : selected === "subtitles" ? (
|
||||||
|
<SubtitlesPanel />
|
||||||
|
) : selected === "sfv" ? (
|
||||||
|
<SfvPanel />
|
||||||
|
) : selected === "filter" ? (
|
||||||
|
<FilterPanel />
|
||||||
|
) : selected === "list" ? (
|
||||||
|
<ListPanel />
|
||||||
|
) : (
|
||||||
|
<PanelStub name={current.name} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
.episodes-panel {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodes-search-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--surface-deep);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodes-search-row-inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodes-search-row-inline > label {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodes-series-field,
|
||||||
|
.episodes-season-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodes-series-field {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodes-series-field label,
|
||||||
|
.episodes-season-field label {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodes-season-field {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodes-season-stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodes-season-input {
|
||||||
|
width: 56px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodes-hint {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodes-results {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodes-results-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 2px 2px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodes-series-name {
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodes-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--surface-deep);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodes-row-number {
|
||||||
|
flex: 0 0 28px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--aurora-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodes-row-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodes-row-date {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodes-empty-note {
|
||||||
|
color: var(--muted-text);
|
||||||
|
font-size: 12.5px;
|
||||||
|
padding: 8px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodes-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--aurora-cyan);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
interface EpisodeRow {
|
||||||
|
episodeNumber: number;
|
||||||
|
title: string;
|
||||||
|
airDate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EpisodeListOutcome =
|
||||||
|
| { status: "found"; seriesName: string; episodes: EpisodeRow[] }
|
||||||
|
| { status: "failed"; error: string };
|
||||||
|
|
||||||
|
export default function EpisodesPanel() {
|
||||||
|
const [seriesQuery, setSeriesQuery] = useState("");
|
||||||
|
const [season, setSeason] = useState(1);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [seriesName, setSeriesName] = useState<string | null>(null);
|
||||||
|
const [episodes, setEpisodes] = useState<EpisodeRow[]>([]);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [hasSearched, setHasSearched] = useState(false);
|
||||||
|
|
||||||
|
async function runBrowse(seasonToUse: number) {
|
||||||
|
const trimmed = seriesQuery.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setStatus("Enter a series name to search for.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearching(true);
|
||||||
|
setStatus(null);
|
||||||
|
try {
|
||||||
|
const outcome = await invoke<EpisodeListOutcome>("browse_episodes", {
|
||||||
|
seriesQuery: trimmed,
|
||||||
|
season: seasonToUse,
|
||||||
|
});
|
||||||
|
setHasSearched(true);
|
||||||
|
if (outcome.status === "found") {
|
||||||
|
setSeriesName(outcome.seriesName);
|
||||||
|
setEpisodes(outcome.episodes);
|
||||||
|
if (outcome.episodes.length === 0) {
|
||||||
|
setStatus(`${outcome.seriesName} — no episodes found for season ${seasonToUse}.`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSeriesName(null);
|
||||||
|
setEpisodes([]);
|
||||||
|
setStatus(outcome.error);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setSeriesName(null);
|
||||||
|
setEpisodes([]);
|
||||||
|
setStatus(String(err));
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSeason(delta: number) {
|
||||||
|
const next = Math.max(1, season + delta);
|
||||||
|
setSeason(next);
|
||||||
|
if (hasSearched) {
|
||||||
|
runBrowse(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyList() {
|
||||||
|
if (!seriesName || episodes.length === 0) return;
|
||||||
|
const lines = episodes.map(
|
||||||
|
(e) => `${seriesName} - ${season}x${String(e.episodeNumber).padStart(2, "0")} - ${e.title}`
|
||||||
|
);
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(lines.join("\n"))
|
||||||
|
.then(() => setStatus(`Copied ${lines.length} episode names to the clipboard.`))
|
||||||
|
.catch(() => setStatus("Couldn't copy to the clipboard."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="episodes-panel">
|
||||||
|
<div className="episodes-search-form">
|
||||||
|
<div className="episodes-search-row-inline">
|
||||||
|
<div className="episodes-series-field">
|
||||||
|
<label htmlFor="episodes-series">Series name</label>
|
||||||
|
<input
|
||||||
|
id="episodes-series"
|
||||||
|
className="modal-text-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Breaking Bad"
|
||||||
|
value={seriesQuery}
|
||||||
|
onChange={(e) => setSeriesQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") runBrowse(season);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="episodes-season-field">
|
||||||
|
<label htmlFor="episodes-season">Season</label>
|
||||||
|
<div className="episodes-season-stepper">
|
||||||
|
<button className="action-button" onClick={() => changeSeason(-1)} disabled={searching || season <= 1}>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
id="episodes-season"
|
||||||
|
className="modal-text-input episodes-season-input"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={season}
|
||||||
|
onChange={(e) => setSeason(Math.max(1, Number(e.target.value) || 1))}
|
||||||
|
/>
|
||||||
|
<button className="action-button" onClick={() => changeSeason(1)} disabled={searching}>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="action-button primary" onClick={() => runBrowse(season)} disabled={searching}>
|
||||||
|
{searching ? "Searching…" : "Browse"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="episodes-hint">
|
||||||
|
Searches whichever TV source (TMDB, TVmaze, or OMDb) is set in the Rename panel's Fetch Data Settings — no
|
||||||
|
separate credentials needed here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="episodes-results">
|
||||||
|
{seriesName && episodes.length > 0 && (
|
||||||
|
<div className="episodes-results-header">
|
||||||
|
<span className="episodes-series-name">
|
||||||
|
{seriesName} — Season {season}
|
||||||
|
</span>
|
||||||
|
<button className="action-button" onClick={copyList}>
|
||||||
|
Copy list
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{episodes.length === 0 && !status && !searching && (
|
||||||
|
<div className="episodes-empty-note">
|
||||||
|
{hasSearched ? "No episodes to show." : "Search a series and season to browse its episode list."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{episodes.map((ep) => (
|
||||||
|
<div className="episodes-row" key={ep.episodeNumber}>
|
||||||
|
<span className="episodes-row-number">{String(ep.episodeNumber).padStart(2, "0")}</span>
|
||||||
|
<span className="episodes-row-title">{ep.title}</span>
|
||||||
|
{ep.airDate && <span className="episodes-row-date">{ep.airDate}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status && <div className="episodes-status">{status}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
.filter-panel {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--surface-deep);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-loaded-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-criteria {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--surface-deep);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-criteria-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-criteria-label {
|
||||||
|
flex: 0 0 130px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--surface-deep);
|
||||||
|
color: var(--muted-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip.selected {
|
||||||
|
background: rgba(82, 199, 219, 0.16);
|
||||||
|
border-color: var(--aurora-cyan);
|
||||||
|
color: var(--aurora-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-name-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-season-input {
|
||||||
|
width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-results {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-results-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 2px 2px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-match-count {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--surface-deep);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row-subtitle {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-empty-note {
|
||||||
|
color: var(--muted-text);
|
||||||
|
font-size: 12.5px;
|
||||||
|
padding: 8px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--aurora-cyan);
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
interface FileEntry {
|
||||||
|
path: string;
|
||||||
|
name: string; // filename without extension
|
||||||
|
ext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same local, filename-only guessers "Match" uses in the Rename panel (aurora-curator-core's
|
||||||
|
// `matching` module) - no network calls, so Filter can scan a freshly-loaded batch
|
||||||
|
// instantly and narrow it down before deciding what to do with it elsewhere.
|
||||||
|
interface EpisodeGuess {
|
||||||
|
seriesName: string;
|
||||||
|
season: number;
|
||||||
|
episode: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MovieGuess {
|
||||||
|
title: string;
|
||||||
|
year: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReleaseInfo {
|
||||||
|
resolution: string | null;
|
||||||
|
source: string | null;
|
||||||
|
videoCodec: string | null;
|
||||||
|
audioCodec: string | null;
|
||||||
|
group: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaKind = "tv" | "movie" | "unmatched";
|
||||||
|
|
||||||
|
interface FileInfo {
|
||||||
|
kind: MediaKind;
|
||||||
|
seriesName?: string;
|
||||||
|
season?: number;
|
||||||
|
episode?: number;
|
||||||
|
movieName?: string;
|
||||||
|
year?: number | null;
|
||||||
|
release: ReleaseInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_LABELS: Record<MediaKind, string> = {
|
||||||
|
tv: "TV",
|
||||||
|
movie: "Movies",
|
||||||
|
unmatched: "Unmatched",
|
||||||
|
};
|
||||||
|
|
||||||
|
function extLabel(ext: string): string {
|
||||||
|
return ext ? ext.toUpperCase() : "(none)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayName(file: FileEntry): string {
|
||||||
|
return file.ext ? `${file.name}.${file.ext}` : file.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subtitleFor(info: FileInfo | undefined): string {
|
||||||
|
if (!info) return "";
|
||||||
|
if (info.kind === "tv") {
|
||||||
|
const se = `S${String(info.season ?? 0).padStart(2, "0")}E${String(info.episode ?? 0).padStart(2, "0")}`;
|
||||||
|
return info.seriesName ? `${info.seriesName} · ${se}` : se;
|
||||||
|
}
|
||||||
|
if (info.kind === "movie") {
|
||||||
|
return info.year ? `${info.movieName} (${info.year})` : info.movieName ?? "";
|
||||||
|
}
|
||||||
|
return "Unmatched";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilterPanel() {
|
||||||
|
const [files, setFiles] = useState<FileEntry[]>([]);
|
||||||
|
const [info, setInfo] = useState<Record<string, FileInfo>>({});
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [extensions, setExtensions] = useState<Set<string>>(new Set());
|
||||||
|
const [kinds, setKinds] = useState<Set<MediaKind>>(new Set());
|
||||||
|
const [nameContains, setNameContains] = useState("");
|
||||||
|
const [season, setSeason] = useState("");
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
const picked = await invoke<FileEntry[]>("pick_files");
|
||||||
|
if (picked.length === 0) return;
|
||||||
|
setFiles(picked);
|
||||||
|
setInfo({});
|
||||||
|
setNameContains("");
|
||||||
|
setSeason("");
|
||||||
|
setStatus(null);
|
||||||
|
await scan(picked);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scan(targets: FileEntry[]) {
|
||||||
|
setScanning(true);
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(
|
||||||
|
targets.map(async (file): Promise<readonly [string, FileInfo]> => {
|
||||||
|
const releasePromise = invoke<ReleaseInfo | null>("guess_release_info", {
|
||||||
|
filename: displayName(file),
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
let fileInfo: FileInfo | null = null;
|
||||||
|
try {
|
||||||
|
const episode = await invoke<EpisodeGuess | null>("guess_episode", { filename: displayName(file) });
|
||||||
|
if (episode) {
|
||||||
|
fileInfo = {
|
||||||
|
kind: "tv",
|
||||||
|
seriesName: episode.seriesName,
|
||||||
|
season: episode.season,
|
||||||
|
episode: episode.episode,
|
||||||
|
release: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through and try a movie guess instead.
|
||||||
|
}
|
||||||
|
if (!fileInfo) {
|
||||||
|
try {
|
||||||
|
const movie = await invoke<MovieGuess | null>("guess_movie", { filename: displayName(file) });
|
||||||
|
if (movie) {
|
||||||
|
fileInfo = { kind: "movie", movieName: movie.title, year: movie.year, release: null };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Neither guess worked.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!fileInfo) fileInfo = { kind: "unmatched", release: null };
|
||||||
|
fileInfo.release = await releasePromise;
|
||||||
|
return [file.path, fileInfo] as const;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextInfo: Record<string, FileInfo> = {};
|
||||||
|
const seenExt = new Set<string>();
|
||||||
|
const seenKinds = new Set<MediaKind>();
|
||||||
|
for (const [path, fileInfo] of results) {
|
||||||
|
nextInfo[path] = fileInfo;
|
||||||
|
seenKinds.add(fileInfo.kind);
|
||||||
|
}
|
||||||
|
for (const file of targets) seenExt.add(file.ext);
|
||||||
|
|
||||||
|
setInfo(nextInfo);
|
||||||
|
setExtensions(seenExt);
|
||||||
|
setKinds(seenKinds);
|
||||||
|
setStatus(`Scanned ${targets.length} file${targets.length === 1 ? "" : "s"} locally (no network calls).`);
|
||||||
|
} finally {
|
||||||
|
setScanning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExtension(ext: string) {
|
||||||
|
setExtensions((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(ext)) next.delete(ext);
|
||||||
|
else next.add(ext);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleKind(kind: MediaKind) {
|
||||||
|
setKinds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(kind)) {
|
||||||
|
next.delete(kind);
|
||||||
|
if (kind === "tv") setSeason("");
|
||||||
|
} else {
|
||||||
|
next.add(kind);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const allExtensions = Array.from(new Set(files.map((f) => f.ext))).sort();
|
||||||
|
const allKinds = (["tv", "movie", "unmatched"] as MediaKind[]).filter((k) =>
|
||||||
|
Object.values(info).some((i) => i.kind === k)
|
||||||
|
);
|
||||||
|
|
||||||
|
const seasonFilter = kinds.has("tv") && season.trim() !== "" ? Number(season) : null;
|
||||||
|
|
||||||
|
const filtered = files.filter((file) => {
|
||||||
|
const fileInfo = info[file.path];
|
||||||
|
if (!fileInfo) return false;
|
||||||
|
if (!extensions.has(file.ext)) return false;
|
||||||
|
if (!kinds.has(fileInfo.kind)) return false;
|
||||||
|
if (nameContains.trim() && !displayName(file).toLowerCase().includes(nameContains.trim().toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (seasonFilter !== null && !(fileInfo.kind === "tv" && fileInfo.season === seasonFilter)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
function copyPaths() {
|
||||||
|
if (filtered.length === 0) return;
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(filtered.map((f) => f.path).join("\n"))
|
||||||
|
.then(() => setStatus(`Copied ${filtered.length} file path${filtered.length === 1 ? "" : "s"} to the clipboard.`))
|
||||||
|
.catch(() => setStatus("Couldn't copy to the clipboard."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="filter-panel">
|
||||||
|
<div className="filter-toolbar">
|
||||||
|
<button className="action-button" onClick={loadFiles}>
|
||||||
|
Load Files…
|
||||||
|
</button>
|
||||||
|
<span className="filter-loaded-count">
|
||||||
|
{files.length > 0 ? `${files.length} file${files.length === 1 ? "" : "s"} loaded` : "No files loaded"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="filter-criteria">
|
||||||
|
{allExtensions.length > 0 && (
|
||||||
|
<div className="filter-criteria-row">
|
||||||
|
<span className="filter-criteria-label">Extension</span>
|
||||||
|
<div className="filter-chip-row" role="group" aria-label="Filter by extension">
|
||||||
|
{allExtensions.map((ext) => (
|
||||||
|
<button
|
||||||
|
key={ext || "(none)"}
|
||||||
|
className={`filter-chip${extensions.has(ext) ? " selected" : ""}`}
|
||||||
|
onClick={() => toggleExtension(ext)}
|
||||||
|
>
|
||||||
|
{extLabel(ext)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{allKinds.length > 0 && (
|
||||||
|
<div className="filter-criteria-row">
|
||||||
|
<span className="filter-criteria-label">Kind</span>
|
||||||
|
<div className="filter-chip-row" role="group" aria-label="Filter by media kind">
|
||||||
|
{allKinds.map((kind) => (
|
||||||
|
<button
|
||||||
|
key={kind}
|
||||||
|
className={`filter-chip${kinds.has(kind) ? " selected" : ""}`}
|
||||||
|
onClick={() => toggleKind(kind)}
|
||||||
|
>
|
||||||
|
{KIND_LABELS[kind]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="filter-criteria-row">
|
||||||
|
<span className="filter-criteria-label">Filename contains</span>
|
||||||
|
<input
|
||||||
|
className="modal-text-input filter-name-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. director's cut"
|
||||||
|
value={nameContains}
|
||||||
|
onChange={(e) => setNameContains(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{kinds.has("tv") && (
|
||||||
|
<div className="filter-criteria-row">
|
||||||
|
<span className="filter-criteria-label">Season</span>
|
||||||
|
<input
|
||||||
|
className="modal-text-input filter-season-input"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
placeholder="any"
|
||||||
|
value={season}
|
||||||
|
onChange={(e) => setSeason(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="filter-results">
|
||||||
|
{files.length === 0 && <div className="filter-empty-note">Load files to filter them by extension, kind, name, or season.</div>}
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="filter-results-header">
|
||||||
|
<span className="filter-match-count">
|
||||||
|
{scanning ? "Scanning…" : `${filtered.length} of ${files.length} files match`}
|
||||||
|
</span>
|
||||||
|
<button className="action-button" onClick={copyPaths} disabled={filtered.length === 0}>
|
||||||
|
Copy File Paths
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{files.length > 0 && !scanning && filtered.length === 0 && (
|
||||||
|
<div className="filter-empty-note">No files match the current filters.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filtered.map((file) => (
|
||||||
|
<div className="filter-row" key={file.path}>
|
||||||
|
<span className="filter-row-name">{displayName(file)}</span>
|
||||||
|
<span className="filter-row-subtitle">{subtitleFor(info[file.path])}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status && <div className="filter-status">{status}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
.list-panel {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--surface-deep);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-loaded-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-format {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--surface-deep);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-format-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-format-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: "SF Mono", Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-format-hint {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
padding: 0 2px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-format-hint code {
|
||||||
|
background: var(--surface-deep);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-results {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-results-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 2px 2px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-results-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-match-count {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-error {
|
||||||
|
color: #ff8a8a;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--surface-deep);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row-line {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row-empty {
|
||||||
|
color: var(--muted-text);
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row-name {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-width: 40%;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-empty-note {
|
||||||
|
color: var(--muted-text);
|
||||||
|
font-size: 12.5px;
|
||||||
|
padding: 8px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--aurora-cyan);
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
interface FileEntry {
|
||||||
|
path: string;
|
||||||
|
name: string; // filename without extension
|
||||||
|
ext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same local, filename-only guessers "Match" (Rename) and the Filter panel's scan
|
||||||
|
// already use (aurora-curator-core's `matching` module) - no network calls, so a
|
||||||
|
// loaded batch can be scanned and formatted instantly.
|
||||||
|
interface EpisodeGuess {
|
||||||
|
seriesName: string;
|
||||||
|
season: number;
|
||||||
|
episode: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MovieGuess {
|
||||||
|
title: string;
|
||||||
|
year: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReleaseInfo {
|
||||||
|
resolution: string | null;
|
||||||
|
source: string | null;
|
||||||
|
videoCodec: string | null;
|
||||||
|
audioCodec: string | null;
|
||||||
|
group: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The naming-scheme-style expression List renders per file, reusing exactly the same
|
||||||
|
// `render_expression` command (and therefore the same `{ }` syntax) the Rename panel's
|
||||||
|
// live preview uses. `{name}`/`{ext}` always work; every other field only fills in once
|
||||||
|
// the local scan below recognizes it (no Fetch Data step here - List stays local-only,
|
||||||
|
// same choice Filter made, since neither panel touches the filesystem or the network).
|
||||||
|
const DEFAULT_EXPRESSION = "{name}";
|
||||||
|
|
||||||
|
function pad2(n: number): string {
|
||||||
|
return n.toString().padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayName(file: FileEntry): string {
|
||||||
|
return file.ext ? `${file.name}.${file.ext}` : file.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ListPanel() {
|
||||||
|
const [files, setFiles] = useState<FileEntry[]>([]);
|
||||||
|
const [bindings, setBindings] = useState<Record<string, Record<string, string>>>({});
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [expression, setExpression] = useState(DEFAULT_EXPRESSION);
|
||||||
|
const [lines, setLines] = useState<Record<string, string>>({});
|
||||||
|
const [computeError, setComputeError] = useState<string | null>(null);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
const picked = await invoke<FileEntry[]>("pick_files");
|
||||||
|
if (picked.length === 0) return;
|
||||||
|
setFiles(picked);
|
||||||
|
const base: Record<string, Record<string, string>> = {};
|
||||||
|
for (const file of picked) base[file.path] = { name: file.name, ext: file.ext };
|
||||||
|
setBindings(base);
|
||||||
|
setStatus(null);
|
||||||
|
await scan(picked, base);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scan(targets: FileEntry[], base: Record<string, Record<string, string>>) {
|
||||||
|
setScanning(true);
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(
|
||||||
|
targets.map(async (file): Promise<readonly [string, Record<string, string>]> => {
|
||||||
|
const releasePromise = invoke<ReleaseInfo | null>("guess_release_info", {
|
||||||
|
filename: displayName(file),
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
let extra: Record<string, string> = {};
|
||||||
|
let matched = false;
|
||||||
|
try {
|
||||||
|
const episode = await invoke<EpisodeGuess | null>("guess_episode", { filename: displayName(file) });
|
||||||
|
if (episode) {
|
||||||
|
matched = true;
|
||||||
|
extra = {
|
||||||
|
n: episode.seriesName,
|
||||||
|
s00e00: `S${pad2(episode.season)}E${pad2(episode.episode)}`,
|
||||||
|
season: String(episode.season),
|
||||||
|
episode: String(episode.episode),
|
||||||
|
"show name": episode.seriesName,
|
||||||
|
"season number": String(episode.season),
|
||||||
|
"episode number": pad2(episode.episode),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through and try a movie guess instead.
|
||||||
|
}
|
||||||
|
if (!matched) {
|
||||||
|
try {
|
||||||
|
const movie = await invoke<MovieGuess | null>("guess_movie", { filename: displayName(file) });
|
||||||
|
if (movie) {
|
||||||
|
extra = {
|
||||||
|
n: movie.title,
|
||||||
|
...(movie.year ? { y: String(movie.year) } : {}),
|
||||||
|
"movie name": movie.title,
|
||||||
|
...(movie.year ? { year: String(movie.year) } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Neither guess worked - the file just keeps its base {name}/{ext} bindings.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const release = await releasePromise;
|
||||||
|
const releaseBindings: Record<string, string> = release
|
||||||
|
? {
|
||||||
|
...(release.resolution ? { vf: release.resolution, resolution: release.resolution } : {}),
|
||||||
|
...(release.source ? { source: release.source } : {}),
|
||||||
|
...(release.videoCodec ? { vc: release.videoCodec, "video codec": release.videoCodec } : {}),
|
||||||
|
...(release.audioCodec ? { ac: release.audioCodec, "audio codec": release.audioCodec } : {}),
|
||||||
|
...(release.group ? { group: release.group, "release group": release.group } : {}),
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return [file.path, { ...base[file.path], ...extra, ...releaseBindings }] as const;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const next: Record<string, Record<string, string>> = {};
|
||||||
|
let matchedCount = 0;
|
||||||
|
for (const [path, b] of results) {
|
||||||
|
next[path] = b;
|
||||||
|
if (b.n) matchedCount += 1;
|
||||||
|
}
|
||||||
|
setBindings(next);
|
||||||
|
setStatus(
|
||||||
|
`Scanned ${targets.length} file${targets.length === 1 ? "" : "s"} locally (${matchedCount} matched; no network calls).`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setScanning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-render every file's line whenever the expression or the underlying bindings
|
||||||
|
// change - same live-preview pattern the Rename panel's naming-scheme editor uses,
|
||||||
|
// via the same `render_expression` command. A single file whose rendered output
|
||||||
|
// ends up empty (e.g. an expression referencing a field this batch never matched)
|
||||||
|
// just shows a blank line rather than blocking the rest of the list.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let firstError: string | null = null;
|
||||||
|
|
||||||
|
Promise.all(
|
||||||
|
files.map(async (file): Promise<readonly [string, string]> => {
|
||||||
|
const b = bindings[file.path];
|
||||||
|
if (!b) return [file.path, ""] as const;
|
||||||
|
try {
|
||||||
|
const rendered = await invoke<string>("render_expression", { expression, bindings: b });
|
||||||
|
return [file.path, rendered] as const;
|
||||||
|
} catch (err) {
|
||||||
|
if (!firstError) firstError = String(err);
|
||||||
|
return [file.path, ""] as const;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).then((entries) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const next: Record<string, string> = {};
|
||||||
|
for (const [path, line] of entries) next[path] = line;
|
||||||
|
setLines(next);
|
||||||
|
setComputeError(firstError);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [expression, files, bindings]);
|
||||||
|
|
||||||
|
const orderedLines = files.map((f) => lines[f.path] ?? "");
|
||||||
|
const nonEmptyCount = orderedLines.filter((l) => l.trim() !== "").length;
|
||||||
|
|
||||||
|
function copyList() {
|
||||||
|
if (files.length === 0) return;
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(orderedLines.join("\n"))
|
||||||
|
.then(() => setStatus(`Copied ${files.length} line${files.length === 1 ? "" : "s"} to the clipboard.`))
|
||||||
|
.catch(() => setStatus("Couldn't copy to the clipboard."));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportList() {
|
||||||
|
if (files.length === 0) return;
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const savedPath = await invoke<string | null>("export_text_list", {
|
||||||
|
defaultName: "list",
|
||||||
|
content: orderedLines.join("\n"),
|
||||||
|
});
|
||||||
|
setStatus(savedPath ? `Saved to ${savedPath}` : "Export cancelled.");
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(String(err));
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="list-panel">
|
||||||
|
<div className="list-toolbar">
|
||||||
|
<button className="action-button" onClick={loadFiles}>
|
||||||
|
Load Files…
|
||||||
|
</button>
|
||||||
|
<span className="list-loaded-count">
|
||||||
|
{files.length > 0 ? `${files.length} file${files.length === 1 ? "" : "s"} loaded` : "No files loaded"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="list-format">
|
||||||
|
<span className="list-format-label">Format</span>
|
||||||
|
<input
|
||||||
|
className="modal-text-input list-format-input"
|
||||||
|
type="text"
|
||||||
|
value={expression}
|
||||||
|
onChange={(e) => setExpression(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="list-format-hint">
|
||||||
|
Same <code>{"{ }"}</code> syntax as the naming scheme. <code>{"{name}"}</code>/<code>{"{ext}"}</code> always work;{" "}
|
||||||
|
<code>{"{show name}"}</code>/<code>{"{season number}"}</code>/<code>{"{episode number}"}</code>,{" "}
|
||||||
|
<code>{"{movie name}"}</code>/<code>{"{year}"}</code>, and release tags fill in once a file is recognized -
|
||||||
|
local matching only, no Fetch Data step.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="list-results">
|
||||||
|
{files.length === 0 && <div className="list-empty-note">Load files to build a formatted list from them.</div>}
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="list-results-header">
|
||||||
|
<span className="list-match-count">
|
||||||
|
{scanning ? "Scanning…" : `${nonEmptyCount} of ${files.length} lines rendered`}
|
||||||
|
</span>
|
||||||
|
<div className="list-results-actions">
|
||||||
|
<button className="action-button" onClick={copyList} disabled={files.length === 0}>
|
||||||
|
Copy List
|
||||||
|
</button>
|
||||||
|
<button className="action-button" onClick={exportList} disabled={exporting || files.length === 0}>
|
||||||
|
{exporting ? "Exporting…" : "Export to File…"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{computeError && <div className="list-error">{computeError}</div>}
|
||||||
|
|
||||||
|
{files.map((file) => (
|
||||||
|
<div className="list-row" key={file.path}>
|
||||||
|
<span className="list-row-line">{lines[file.path] || <span className="list-row-empty">(empty)</span>}</span>
|
||||||
|
<span className="list-row-name">{displayName(file)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status && <div className="list-status">{status}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -530,6 +530,14 @@
|
|||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-inline-remove {
|
||||||
|
width: auto;
|
||||||
|
align-self: flex-start;
|
||||||
|
margin: 6px 0 4px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
.history-batch-list {
|
.history-batch-list {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ interface FileEntry {
|
|||||||
ext: string;
|
ext: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result of the local, filename-only season/episode matcher (renamr-core's
|
// Result of the local, filename-only season/episode matcher (aurora-curator-core's
|
||||||
// `matching` module, exposed via the `guess_episode` Tauri command). No network calls -
|
// `matching` module, exposed via the `guess_episode` Tauri command). No network calls -
|
||||||
// this only recognizes patterns already present in the filename text itself.
|
// this only recognizes patterns already present in the filename text itself.
|
||||||
interface EpisodeGuess {
|
interface EpisodeGuess {
|
||||||
@@ -40,7 +40,7 @@ interface EpisodeGuess {
|
|||||||
episode: number;
|
episode: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result of the local, filename-only movie title/year matcher (renamr-core's
|
// Result of the local, filename-only movie title/year matcher (aurora-curator-core's
|
||||||
// `matching::guess_movie`, exposed via the `guess_movie` Tauri command). Tried as a
|
// `matching::guess_movie`, exposed via the `guess_movie` Tauri command). Tried as a
|
||||||
// fallback whenever `guess_episode` finds no season/episode pattern - a release name
|
// fallback whenever `guess_episode` finds no season/episode pattern - a release name
|
||||||
// is either a TV episode or a movie, never both.
|
// is either a TV episode or a movie, never both.
|
||||||
@@ -52,7 +52,7 @@ interface MovieGuess {
|
|||||||
// What runMatching resolved a file to, before it's written into the item's bindings.
|
// What runMatching resolved a file to, before it's written into the item's bindings.
|
||||||
type MatchGuess = { kind: "tv"; episode: EpisodeGuess } | { kind: "movie"; movie: MovieGuess };
|
type MatchGuess = { kind: "tv"; episode: EpisodeGuess } | { kind: "movie"; movie: MovieGuess };
|
||||||
|
|
||||||
// Result of the local, filename-only release-tag matcher (renamr-core's
|
// Result of the local, filename-only release-tag matcher (aurora-curator-core's
|
||||||
// `matching::guess_release_info`, exposed via the `guess_release_info` Tauri command).
|
// `matching::guess_release_info`, exposed via the `guess_release_info` Tauri command).
|
||||||
// Independent of the TV/movie guess above - a release name carries (or doesn't carry)
|
// Independent of the TV/movie guess above - a release name carries (or doesn't carry)
|
||||||
// these tags regardless of what kind of media it is, so this runs alongside whichever
|
// these tags regardless of what kind of media it is, so this runs alongside whichever
|
||||||
@@ -65,6 +65,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/duration/codecs read
|
||||||
|
// straight from the file's own header, not guessed from its name. Complements (never
|
||||||
|
// overrides) `ReleaseInfo` above: a filename-tag value wins whenever a file has both,
|
||||||
|
// since a release group's own tag is usually deliberate. `resolution`/`duration`/
|
||||||
|
// `fileSize` are pre-formatted display strings computed on the Rust side, so this
|
||||||
|
// component never reimplements that formatting.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
function pad2(n: number): string {
|
function pad2(n: number): string {
|
||||||
return n.toString().padStart(2, "0");
|
return n.toString().padStart(2, "0");
|
||||||
}
|
}
|
||||||
@@ -78,7 +104,7 @@ interface RenamePlanEntry {
|
|||||||
newName: string;
|
newName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors renamr-core's `RenameOutcome` (serde-tagged on "status"). Conflict policy is
|
// Mirrors aurora-curator-core's `RenameOutcome` (serde-tagged on "status"). Conflict policy is
|
||||||
// fixed at skip - a colliding target is never overwritten, just reported.
|
// fixed at skip - a colliding target is never overwritten, just reported.
|
||||||
type RenameOutcome =
|
type RenameOutcome =
|
||||||
| { status: "renamed"; newPath: string }
|
| { status: "renamed"; newPath: string }
|
||||||
@@ -91,23 +117,36 @@ interface RenameResult {
|
|||||||
outcome: RenameOutcome;
|
outcome: RenameOutcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors renamr-core's `RenameAction` - what actually happens at each computed target.
|
// Mirrors aurora-curator-core's `RenameAction` - what actually happens at each computed target.
|
||||||
// "move" relocates the file in place (the only action Renamr had before); "copy" and
|
// "move" relocates the file in place (the only action Aurora Curator had before); "copy" and
|
||||||
// "symlink" both leave the source file exactly where it is and produce a new file
|
// "symlink" both leave the source file exactly where it is and produce a new file
|
||||||
// elsewhere, which is why only "move" updates the loaded item's own path/name (see
|
// elsewhere, which is why only "move" updates the loaded item's own path/name (see
|
||||||
// confirmRename below).
|
// confirmRename below).
|
||||||
type RenameAction = "move" | "copy" | "symlink";
|
type RenameAction = "move" | "copy" | "symlink";
|
||||||
|
|
||||||
// Mirrors renamr-core's `ConflictPolicy` - what happens when a computed target already
|
// Mirrors aurora-curator-core's `ConflictPolicy` - what happens when a computed target already
|
||||||
// exists. "skip" (the default, and the only behavior Renamr had before) leaves the
|
// exists. "skip" (the default, and the only behavior Aurora Curator had before) leaves the
|
||||||
// conflicting file alone; "autoNumber" saves alongside it as "Name (1).ext" instead;
|
// conflicting file alone; "autoNumber" saves alongside it as "Name (1).ext" instead;
|
||||||
// "override" replaces it outright (irreversible for whatever gets overwritten).
|
// "override" replaces it outright (irreversible for whatever gets overwritten).
|
||||||
type ConflictPolicy = "skip" | "autoNumber" | "override";
|
type ConflictPolicy = "skip" | "autoNumber" | "override";
|
||||||
|
|
||||||
|
// Mirrors aurora-curator-core's `TvMetadataSource` - which online source Fetch Data
|
||||||
|
// resolves TV episodes against. "tmdb" is the default (matches Aurora Curator's original and
|
||||||
|
// only source before TVmaze existed); "tvmaze" needs no API token at all; "omdb" needs
|
||||||
|
// its own separate API key (see MovieMetadataSource below - the two settings are
|
||||||
|
// independent, since OMDb covers both TV and movies but TVmaze only covers TV).
|
||||||
|
type TvMetadataSource = "tmdb" | "tvmaze" | "omdb";
|
||||||
|
|
||||||
|
// Mirrors aurora-curator-core's `MovieMetadataSource` - which online source Fetch Data
|
||||||
|
// resolves movies against. Unlike TV there's no zero-setup option (TVmaze has no movie
|
||||||
|
// catalog) - both choices need their own saved credential.
|
||||||
|
type MovieMetadataSource = "tmdb" | "omdb";
|
||||||
|
|
||||||
// Sent to `fetch_metadata`: one file's local guess (from Match) to resolve against
|
// Sent to `fetch_metadata`: one file's local guess (from Match) to resolve against
|
||||||
// real TMDB data. Needs a series name + season/episode to search with - exactly what
|
// real online TV metadata (TMDB or TVmaze, per the stored TvMetadataSource). Needs a
|
||||||
// Match already produces offline, which is why Fetch Data requires Match to have run
|
// series name + season/episode to search with - exactly what Match already produces
|
||||||
// first rather than doing its own from-scratch identification.
|
// offline, which is why Fetch Data requires Match to have run first rather than doing
|
||||||
|
// its own from-scratch identification.
|
||||||
interface FetchQuery {
|
interface FetchQuery {
|
||||||
id: string;
|
id: string;
|
||||||
seriesQuery: string;
|
seriesQuery: string;
|
||||||
@@ -115,7 +154,7 @@ interface FetchQuery {
|
|||||||
episode: number;
|
episode: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors renamr-core's `FetchOutcome` (serde-tagged on "status").
|
// Mirrors aurora-curator-core's `FetchOutcome` (serde-tagged on "status").
|
||||||
type FetchOutcome =
|
type FetchOutcome =
|
||||||
| { status: "found"; seriesName: string; episodeTitle: string; airDate: string | null }
|
| { status: "found"; seriesName: string; episodeTitle: string; airDate: string | null }
|
||||||
| { status: "notFound" }
|
| { status: "notFound" }
|
||||||
@@ -135,7 +174,7 @@ interface MovieFetchQuery {
|
|||||||
year: number | null;
|
year: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors renamr-core's `MovieFetchOutcome` (serde-tagged on "status").
|
// Mirrors aurora-curator-core's `MovieFetchOutcome` (serde-tagged on "status").
|
||||||
type MovieFetchOutcome =
|
type MovieFetchOutcome =
|
||||||
| { status: "found"; title: string; releaseDate: string | null }
|
| { status: "found"; title: string; releaseDate: string | null }
|
||||||
| { status: "notFound" }
|
| { status: "notFound" }
|
||||||
@@ -146,7 +185,7 @@ interface MovieFetchResult {
|
|||||||
outcome: MovieFetchOutcome;
|
outcome: MovieFetchOutcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename history / undo. Mirrors renamr-core's `history` module (serde-tagged on
|
// Rename history / undo. Mirrors aurora-curator-core's `history` module (serde-tagged on
|
||||||
// "status" for the outcome, same convention as RenameOutcome/FetchOutcome above).
|
// "status" for the outcome, same convention as RenameOutcome/FetchOutcome above).
|
||||||
interface HistoryEntry {
|
interface HistoryEntry {
|
||||||
source: string;
|
source: string;
|
||||||
@@ -263,9 +302,9 @@ const SAMPLE_ITEMS: Item[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// The user's own preferred naming scheme (their words: "I personally like {show name} -
|
// The user's own preferred naming scheme (their words: "I personally like {show name} -
|
||||||
// {season number}x{episode number} - {episode name}") - this is Renamr's default now,
|
// {season number}x{episode number} - {episode name}") - this is Aurora Curator's default now,
|
||||||
// not just an example. `{show name}`/`{season number}`/etc. are multi-word plain-field
|
// not just an example. `{show name}`/`{season number}`/etc. are multi-word plain-field
|
||||||
// binding names, which work via renamr-core's ExpressionFormat plain-field-name
|
// binding names, which work via aurora-curator-core's ExpressionFormat plain-field-name
|
||||||
// fallback (ExpressionError-free even though they aren't valid Rhai identifiers).
|
// fallback (ExpressionError-free even though they aren't valid Rhai identifiers).
|
||||||
const SAMPLE_EXPRESSION = "{show name} - {season number}x{episode number} - {episode name}";
|
const SAMPLE_EXPRESSION = "{show name} - {season number}x{episode number} - {episode name}";
|
||||||
// The matching engine isn't ported yet, so real loaded files only have their own
|
// The matching engine isn't ported yet, so real loaded files only have their own
|
||||||
@@ -312,6 +351,36 @@ const CONFLICT_POLICY_LABELS: Record<ConflictPolicy, { button: string; descripti
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Copy for each TvMetadataSource, used in the Fetch Data Settings modal's segmented
|
||||||
|
// selector and its description note.
|
||||||
|
const TV_SOURCE_LABELS: Record<TvMetadataSource, { button: string; description: string }> = {
|
||||||
|
tmdb: {
|
||||||
|
button: "TMDB",
|
||||||
|
description: "Uses TheMovieDB for TV episode titles and air dates — needs the TMDB API token below.",
|
||||||
|
},
|
||||||
|
tvmaze: {
|
||||||
|
button: "TVmaze",
|
||||||
|
description: "Uses TVmaze for TV episode titles and air dates — free, no API token needed at all.",
|
||||||
|
},
|
||||||
|
omdb: {
|
||||||
|
button: "OMDb",
|
||||||
|
description: "Uses OMDb for TV episode titles and air dates — needs the OMDb API key below.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Copy for each MovieMetadataSource, used in the Fetch Data Settings modal's own
|
||||||
|
// movie-source segmented selector and its description note. Mirrors TV_SOURCE_LABELS.
|
||||||
|
const MOVIE_SOURCE_LABELS: Record<MovieMetadataSource, { button: string; description: string }> = {
|
||||||
|
tmdb: {
|
||||||
|
button: "TMDB",
|
||||||
|
description: "Uses TheMovieDB for movie titles and release dates — needs the TMDB API token below.",
|
||||||
|
},
|
||||||
|
omdb: {
|
||||||
|
button: "OMDb",
|
||||||
|
description: "Uses OMDb for movie titles and release dates — needs the OMDb API key below.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function realFileToItem(entry: FileEntry): Item {
|
function realFileToItem(entry: FileEntry): Item {
|
||||||
return {
|
return {
|
||||||
id: entry.path,
|
id: entry.path,
|
||||||
@@ -395,18 +464,29 @@ export default function RenamePanel() {
|
|||||||
const [renaming, setRenaming] = useState(false);
|
const [renaming, setRenaming] = useState(false);
|
||||||
// What to actually do at each computed target - chosen in the confirmation modal,
|
// What to actually do at each computed target - chosen in the confirmation modal,
|
||||||
// remembered across modal opens within the session (defaults to "move", matching
|
// remembered across modal opens within the session (defaults to "move", matching
|
||||||
// Renamr's original and only behavior before copy/symlink existed).
|
// Aurora Curator's original and only behavior before copy/symlink existed).
|
||||||
const [renameAction, setRenameAction] = useState<RenameAction>("move");
|
const [renameAction, setRenameAction] = useState<RenameAction>("move");
|
||||||
// What to do when a computed target already exists - same remembered-across-opens
|
// What to do when a computed target already exists - same remembered-across-opens
|
||||||
// pattern as renameAction, defaults to "skip" (Renamr's original and only behavior
|
// pattern as renameAction, defaults to "skip" (Aurora Curator's original and only behavior
|
||||||
// before auto-number/override existed).
|
// before auto-number/override existed).
|
||||||
const [conflictPolicy, setConflictPolicy] = useState<ConflictPolicy>("skip");
|
const [conflictPolicy, setConflictPolicy] = useState<ConflictPolicy>("skip");
|
||||||
// TMDB token state for "Fetch Data". `null` = not checked yet, `false` = checked and
|
// TMDB token state for "Fetch Data". `null` = not checked yet, `false` = checked and
|
||||||
// nothing saved, `true` = a token is on file (the value itself never comes back to
|
// nothing saved, `true` = a token is on file (the value itself never comes back to
|
||||||
// the frontend after saving - see Settings modal below).
|
// the frontend after saving - see Settings modal below).
|
||||||
const [hasTmdbToken, setHasTmdbToken] = useState<boolean | null>(null);
|
const [hasTmdbToken, setHasTmdbToken] = useState<boolean | null>(null);
|
||||||
|
// Same idea as hasTmdbToken, but for the OMDb API key - the two credentials are
|
||||||
|
// entirely independent, since either source can be picked for TV, movies, or both.
|
||||||
|
const [hasOmdbKey, setHasOmdbKey] = useState<boolean | null>(null);
|
||||||
|
// Which online source Fetch Data uses for TV episodes - persisted on disk (see
|
||||||
|
// `src-tauri/src/settings.rs`), defaults to "tmdb" until loaded.
|
||||||
|
const [tvSource, setTvSource] = useState<TvMetadataSource>("tmdb");
|
||||||
|
// Which online source Fetch Data uses for movies - persisted separately from
|
||||||
|
// tvSource (see `src-tauri/src/settings.rs`'s MovieMetadataSource), defaults to
|
||||||
|
// "tmdb" until loaded. TVmaze isn't an option here - it has no movie catalog.
|
||||||
|
const [movieSource, setMovieSource] = useState<MovieMetadataSource>("tmdb");
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [tmdbTokenInput, setTmdbTokenInput] = useState("");
|
const [tmdbTokenInput, setTmdbTokenInput] = 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);
|
||||||
// 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
|
||||||
@@ -438,6 +518,24 @@ export default function RenamePanel() {
|
|||||||
.catch(() => setHasTmdbToken(false));
|
.catch(() => setHasTmdbToken(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invoke<string | null>("get_omdb_api_key")
|
||||||
|
.then((key) => setHasOmdbKey(!!key))
|
||||||
|
.catch(() => setHasOmdbKey(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invoke<TvMetadataSource>("get_tv_metadata_source")
|
||||||
|
.then(setTvSource)
|
||||||
|
.catch(() => setTvSource("tmdb"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invoke<MovieMetadataSource>("get_movie_metadata_source")
|
||||||
|
.then(setMovieSource)
|
||||||
|
.catch(() => setMovieSource("tmdb"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Refs so the drag-and-drop listener (registered once on mount) always sees the
|
// Refs so the drag-and-drop listener (registered once on mount) always sees the
|
||||||
// latest expression/items instead of the stale values from its first render.
|
// latest expression/items instead of the stale values from its first render.
|
||||||
const expressionRef = useRef(expression);
|
const expressionRef = useRef(expression);
|
||||||
@@ -545,13 +643,17 @@ export default function RenamePanel() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Local, filename-based season/episode/movie detection (renamr-core's `matching`
|
// Local, filename-based season/episode/movie detection (aurora-curator-core's `matching`
|
||||||
// module). Tries `guess_episode` first; when a filename carries no season/episode
|
// module). Tries `guess_episode` first; when a filename carries no season/episode
|
||||||
// marker, falls back to `guess_movie` (a release name is either a TV episode or a
|
// marker, falls back to `guess_movie` (a release name is either a TV episode or a
|
||||||
// movie, never both - see the doc comment on `guess_movie` in renamr-core). Populates
|
// movie, never both - see the doc comment on `guess_movie` in aurora-curator-core). Populates
|
||||||
// TV bindings (`{n}`/`{s00e00}`/`{season}`/`{episode}`/etc.) or movie bindings
|
// TV bindings (`{n}`/`{s00e00}`/`{season}`/`{episode}`/etc.) or movie bindings
|
||||||
// (`{n}`/`{y}`/etc.) accordingly - no network calls, no metadata lookup, so it's
|
// (`{n}`/`{y}`/etc.) accordingly - no network calls, no metadata lookup, so it's
|
||||||
// distinct from (and much cheaper than) "Fetch Data" online matching.
|
// distinct from (and much cheaper than) "Fetch Data" online matching. Alongside all
|
||||||
|
// of that, also probes each target's real file (aurora-curator-core's `probe` module via the
|
||||||
|
// batched `probe_media_files` command) for resolution/duration/codecs read straight
|
||||||
|
// from its container header - see the bindings merge below for how a probed value
|
||||||
|
// and a filename-tag value combine.
|
||||||
const runMatching = async () => {
|
const runMatching = async () => {
|
||||||
const targets = items.filter((i) => !i.isSample);
|
const targets = items.filter((i) => !i.isSample);
|
||||||
if (targets.length === 0) {
|
if (targets.length === 0) {
|
||||||
@@ -561,6 +663,12 @@ export default function RenamePanel() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One batched round trip for real probing of every target, run alongside the
|
||||||
|
// per-file filename guessing below rather than after it.
|
||||||
|
const probePromise = invoke<ProbeOutcome[]>("probe_media_files", {
|
||||||
|
paths: targets.map((item) => item.id),
|
||||||
|
}).catch(() => [] as ProbeOutcome[]);
|
||||||
|
|
||||||
const results: Array<readonly [string, MatchGuess | null, ReleaseInfo | null]> = await Promise.all(
|
const results: Array<readonly [string, MatchGuess | null, ReleaseInfo | null]> = await Promise.all(
|
||||||
targets.map(async (item): Promise<readonly [string, MatchGuess | null, ReleaseInfo | null]> => {
|
targets.map(async (item): Promise<readonly [string, MatchGuess | null, ReleaseInfo | null]> => {
|
||||||
// Release tags (resolution/source/codecs/group) are independent of TV-vs-movie,
|
// Release tags (resolution/source/codecs/group) are independent of TV-vs-movie,
|
||||||
@@ -594,6 +702,7 @@ export default function RenamePanel() {
|
|||||||
);
|
);
|
||||||
const guesses = new Map(results.map(([id, guess]) => [id, guess] as const));
|
const guesses = new Map(results.map(([id, guess]) => [id, guess] as const));
|
||||||
const releaseGuesses = new Map(results.map(([id, , release]) => [id, release] as const));
|
const releaseGuesses = new Map(results.map(([id, , release]) => [id, release] as const));
|
||||||
|
const probes = new Map((await probePromise).map((outcome) => [outcome.path, outcome.probe] as const));
|
||||||
// Count matches from the results directly, not inside the setItems updater below:
|
// Count matches from the results directly, not inside the setItems updater below:
|
||||||
// React doesn't run a functional updater synchronously, so a counter mutated in
|
// React doesn't run a functional updater synchronously, so a counter mutated in
|
||||||
// there wouldn't be ready yet for the status message/auto-upgrade check right after.
|
// there wouldn't be ready yet for the status message/auto-upgrade check right after.
|
||||||
@@ -601,11 +710,13 @@ export default function RenamePanel() {
|
|||||||
const matchedMovie = results.filter(([, guess]) => guess?.kind === "movie").length;
|
const matchedMovie = results.filter(([, guess]) => guess?.kind === "movie").length;
|
||||||
const matched = matchedTv + matchedMovie;
|
const matched = matchedTv + matchedMovie;
|
||||||
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;
|
||||||
|
|
||||||
setItems((prev) =>
|
setItems((prev) =>
|
||||||
prev.map((item) => {
|
prev.map((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);
|
||||||
|
|
||||||
// Release-tag fields apply on top of whatever the TV/movie guess below
|
// Release-tag fields apply on top of whatever the TV/movie guess below
|
||||||
// contributes (or nothing at all, for a file with no season/episode or movie
|
// contributes (or nothing at all, for a file with no season/episode or movie
|
||||||
@@ -620,8 +731,34 @@ export default function RenamePanel() {
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Real-probed fields: `resolution`/`video codec`/`audio codec` only fill in
|
||||||
|
// when the filename tag above didn't already provide that field (an explicit
|
||||||
|
// release-group tag wins over a probed value), but `width`/`height`/`fps`/
|
||||||
|
// `duration`/`file size` have no filename-tag equivalent at all, so those
|
||||||
|
// always apply whenever probing found them.
|
||||||
|
const probeBindings: Record<string, string> | null = 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 }
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
if (!guess) {
|
if (!guess) {
|
||||||
return releaseBindings ? { ...item, bindings: { ...item.bindings, ...releaseBindings } } : item;
|
return releaseBindings || probeBindings
|
||||||
|
? { ...item, bindings: { ...item.bindings, ...probeBindings, ...releaseBindings } }
|
||||||
|
: item;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (guess.kind === "tv") {
|
if (guess.kind === "tv") {
|
||||||
@@ -641,6 +778,7 @@ export default function RenamePanel() {
|
|||||||
"show name": seriesName,
|
"show name": seriesName,
|
||||||
"season number": String(episode.season),
|
"season number": String(episode.season),
|
||||||
"episode number": pad2(episode.episode),
|
"episode number": pad2(episode.episode),
|
||||||
|
...probeBindings,
|
||||||
...releaseBindings,
|
...releaseBindings,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -659,6 +797,7 @@ export default function RenamePanel() {
|
|||||||
// Human-readable aliases.
|
// Human-readable aliases.
|
||||||
"movie name": title,
|
"movie name": title,
|
||||||
...(movie.year ? { year: String(movie.year) } : {}),
|
...(movie.year ? { year: String(movie.year) } : {}),
|
||||||
|
...probeBindings,
|
||||||
...releaseBindings,
|
...releaseBindings,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -675,7 +814,7 @@ export default function RenamePanel() {
|
|||||||
|
|
||||||
setStatus(
|
setStatus(
|
||||||
matched > 0
|
matched > 0
|
||||||
? `Matched ${matched} of ${targets.length} file${targets.length === 1 ? "" : "s"} (${matchedTv} TV, ${matchedMovie} movie${matchedMovie === 1 ? "" : "s"}${tagged > 0 ? `, ${tagged} with release tags` : ""}; filename-based, local only).`
|
? `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."
|
||||||
);
|
);
|
||||||
window.clearTimeout((runMatching as any)._t);
|
window.clearTimeout((runMatching as any)._t);
|
||||||
@@ -727,7 +866,7 @@ export default function RenamePanel() {
|
|||||||
// local state and report the outcome. Only "move" relocates the source file, so only
|
// local state and report the outcome. Only "move" relocates the source file, so only
|
||||||
// "move" updates a loaded item's own id/name/bindings to point at the new path -
|
// "move" updates a loaded item's own id/name/bindings to point at the new path -
|
||||||
// "copy"/"symlink" leave the source exactly where it was and produce an additional
|
// "copy"/"symlink" leave the source exactly where it was and produce an additional
|
||||||
// file elsewhere that Renamr doesn't separately track as a loaded item.
|
// file elsewhere that Aurora Curator doesn't separately track as a loaded item.
|
||||||
const confirmRename = async () => {
|
const confirmRename = async () => {
|
||||||
if (!renamePlan || renamePlan.length === 0) return;
|
if (!renamePlan || renamePlan.length === 0) return;
|
||||||
setRenaming(true);
|
setRenaming(true);
|
||||||
@@ -805,7 +944,7 @@ export default function RenamePanel() {
|
|||||||
setRenamePlan((prev) => (prev ? prev.filter((p) => p.id !== id) : prev));
|
setRenamePlan((prev) => (prev ? prev.filter((p) => p.id !== id) : prev));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Rename history / undo - Renamr's only safety net beyond the confirm-before-execute
|
// Rename history / undo - Aurora Curator's only safety net beyond the confirm-before-execute
|
||||||
// step, since a filesystem rename has no OS-level undo. `execute_rename` records a
|
// step, since a filesystem rename has no OS-level undo. `execute_rename` records a
|
||||||
// batch of every successfully-renamed file after each Rename; this panel lists those
|
// batch of every successfully-renamed file after each Rename; this panel lists those
|
||||||
// batches and can revert one back to its original paths.
|
// batches and can revert one back to its original paths.
|
||||||
@@ -877,27 +1016,40 @@ export default function RenamePanel() {
|
|||||||
|
|
||||||
const openSettings = () => {
|
const openSettings = () => {
|
||||||
setTmdbTokenInput("");
|
setTmdbTokenInput("");
|
||||||
|
setOmdbKeyInput("");
|
||||||
setSettingsOpen(true);
|
setSettingsOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveTmdbToken = async () => {
|
// Saves whichever credential field(s) the user actually typed into, then closes the
|
||||||
const trimmed = tmdbTokenInput.trim();
|
// modal - a single Save button covers both the TMDB token and the OMDb key so
|
||||||
if (!trimmed) {
|
// filling in one (or both) at once only takes one click, mirroring how the two
|
||||||
|
// segmented source pickers above it already sit side by side in the same modal.
|
||||||
|
const saveCredentials = async () => {
|
||||||
|
const trimmedTmdb = tmdbTokenInput.trim();
|
||||||
|
const trimmedOmdb = omdbKeyInput.trim();
|
||||||
|
if (!trimmedTmdb && !trimmedOmdb) {
|
||||||
// Nothing typed - just close, leaving whatever was already saved untouched.
|
// Nothing typed - just close, leaving whatever was already saved untouched.
|
||||||
setSettingsOpen(false);
|
setSettingsOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSavingSettings(true);
|
setSavingSettings(true);
|
||||||
try {
|
try {
|
||||||
await invoke("save_tmdb_token", { token: trimmed });
|
if (trimmedTmdb) {
|
||||||
setHasTmdbToken(true);
|
await invoke("save_tmdb_token", { token: trimmedTmdb });
|
||||||
|
setHasTmdbToken(true);
|
||||||
|
}
|
||||||
|
if (trimmedOmdb) {
|
||||||
|
await invoke("save_omdb_api_key", { key: trimmedOmdb });
|
||||||
|
setHasOmdbKey(true);
|
||||||
|
}
|
||||||
setTmdbTokenInput("");
|
setTmdbTokenInput("");
|
||||||
|
setOmdbKeyInput("");
|
||||||
setSettingsOpen(false);
|
setSettingsOpen(false);
|
||||||
setStatus("TMDB token saved.");
|
setStatus("Fetch Data settings saved.");
|
||||||
window.clearTimeout((saveTmdbToken as any)._t);
|
window.clearTimeout((saveCredentials as any)._t);
|
||||||
(saveTmdbToken as any)._t = window.setTimeout(() => setStatus(null), 3000);
|
(saveCredentials as any)._t = window.setTimeout(() => setStatus(null), 3000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(`Couldn't save the token: ${err}`);
|
setStatus(`Couldn't save: ${err}`);
|
||||||
} finally {
|
} finally {
|
||||||
setSavingSettings(false);
|
setSavingSettings(false);
|
||||||
}
|
}
|
||||||
@@ -919,21 +1071,59 @@ export default function RenamePanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// "Fetch Data": resolve each already-Matched file's local guess against real TMDB
|
// Persists the TV-source choice (see Fetch Data Settings modal below). Applied
|
||||||
|
// immediately in the UI regardless of whether the save round-trip succeeds, same as
|
||||||
|
// the rest of this app's settings - a failed disk write shouldn't block using the
|
||||||
|
// app for the rest of the session, just mean the choice doesn't survive a restart.
|
||||||
|
const saveTvSource = async (source: TvMetadataSource) => {
|
||||||
|
setTvSource(source);
|
||||||
|
try {
|
||||||
|
await invoke("save_tv_metadata_source", { source });
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Couldn't save the TV source setting: ${err}`);
|
||||||
|
window.clearTimeout((saveTvSource as any)._t);
|
||||||
|
(saveTvSource as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Same idea as saveTvSource, for the movie-source picker.
|
||||||
|
const saveMovieSource = async (source: MovieMetadataSource) => {
|
||||||
|
setMovieSource(source);
|
||||||
|
try {
|
||||||
|
await invoke("save_movie_metadata_source", { source });
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Couldn't save the movie source setting: ${err}`);
|
||||||
|
window.clearTimeout((saveMovieSource as any)._t);
|
||||||
|
(saveMovieSource as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearOmdbApiKey = async () => {
|
||||||
|
setSavingSettings(true);
|
||||||
|
try {
|
||||||
|
await invoke("save_omdb_api_key", { key: "" });
|
||||||
|
setHasOmdbKey(false);
|
||||||
|
setOmdbKeyInput("");
|
||||||
|
setStatus("OMDb API key removed.");
|
||||||
|
window.clearTimeout((clearOmdbApiKey as any)._t);
|
||||||
|
(clearOmdbApiKey as any)._t = window.setTimeout(() => setStatus(null), 3000);
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Couldn't remove the key: ${err}`);
|
||||||
|
} finally {
|
||||||
|
setSavingSettings(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// "Fetch Data": resolve each already-Matched file's local guess against real online
|
||||||
// metadata. Requires Match to have already populated either a series name + season/
|
// metadata. Requires Match to have already populated either a series name + season/
|
||||||
// episode (TV) or a movie title (movie) - that's the search query - so this is
|
// episode (TV) or a movie title (movie) - that's the search query - so this is
|
||||||
// deliberately layered on top of local matching, not a replacement for it. TV and
|
// deliberately layered on top of local matching, not a replacement for it. TV items
|
||||||
// movie items are resolved via separate Tauri commands, run concurrently, and merged
|
// resolve against whichever source `tvSource` currently picks (TMDB, TVmaze, or
|
||||||
// back into a single set of counts/status message.
|
// OMDb); movie items resolve against whichever source `movieSource` picks (TMDB or
|
||||||
|
// OMDb) - the two settings are independent, so a batch mixing TV and movies can even
|
||||||
|
// use different sources for each half. Both run via separate Tauri commands,
|
||||||
|
// concurrently, and merge back into a single set of counts/status message.
|
||||||
const runFetch = async () => {
|
const runFetch = async () => {
|
||||||
if (hasTmdbToken === false) {
|
|
||||||
setStatus("Add a free TMDB API token in Settings first, then Fetch Data will work.");
|
|
||||||
setSettingsOpen(true);
|
|
||||||
window.clearTimeout((runFetch as any)._t);
|
|
||||||
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tvTargets = items.filter(
|
const tvTargets = items.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
|
||||||
);
|
);
|
||||||
@@ -947,6 +1137,25 @@ export default function RenamePanel() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TVmaze needs no credential at all, so TMDB/OMDb are only actually required when
|
||||||
|
// this batch's TV or movie targets are actually pointed at that source - a TV-only
|
||||||
|
// batch on TVmaze can run with neither credential configured at all.
|
||||||
|
const needsTmdbToken =
|
||||||
|
(movieTargets.length > 0 && movieSource === "tmdb") || (tvTargets.length > 0 && tvSource === "tmdb");
|
||||||
|
const needsOmdbKey =
|
||||||
|
(movieTargets.length > 0 && movieSource === "omdb") || (tvTargets.length > 0 && tvSource === "omdb");
|
||||||
|
if ((needsTmdbToken && hasTmdbToken === false) || (needsOmdbKey && hasOmdbKey === false)) {
|
||||||
|
const missing = [
|
||||||
|
needsTmdbToken && hasTmdbToken === false ? "a free TMDB API token" : null,
|
||||||
|
needsOmdbKey && hasOmdbKey === false ? "a free OMDb API key" : null,
|
||||||
|
].filter((m): m is string => m !== null);
|
||||||
|
setStatus(`Add ${missing.join(" and ")} in Settings first, then Fetch Data will work.`);
|
||||||
|
setSettingsOpen(true);
|
||||||
|
window.clearTimeout((runFetch as any)._t);
|
||||||
|
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setFetching(true);
|
setFetching(true);
|
||||||
try {
|
try {
|
||||||
const [tvResults, movieResults] = await Promise.all([
|
const [tvResults, movieResults] = await Promise.all([
|
||||||
@@ -1048,7 +1257,7 @@ export default function RenamePanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parts = [`Fetched real metadata for ${found} of ${targetCount} file${targetCount === 1 ? "" : "s"}`];
|
const parts = [`Fetched real metadata for ${found} of ${targetCount} file${targetCount === 1 ? "" : "s"}`];
|
||||||
if (notFound > 0) parts.push(`${notFound} not found on TMDB`);
|
if (notFound > 0) parts.push(`${notFound} not found`);
|
||||||
if (failed > 0) parts.push(`${failed} failed`);
|
if (failed > 0) parts.push(`${failed} failed`);
|
||||||
setStatus(parts.join(", ") + ".");
|
setStatus(parts.join(", ") + ".");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1239,7 +1448,7 @@ export default function RenamePanel() {
|
|||||||
<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" onClick={runFetch} disabled={fetching} />
|
||||||
<ListButton icon={<KeyIcon />} label="TMDB 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)} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -1306,15 +1515,32 @@ export default function RenamePanel() {
|
|||||||
<code>{"{audio codec}"}</code>
|
<code>{"{audio codec}"}</code>
|
||||||
<code>{"{release group}"}</code>
|
<code>{"{release group}"}</code>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="format-fields-group">
|
||||||
|
<span className="format-fields-group-title">
|
||||||
|
Real file probing, after <strong>Match</strong> (reads the actual file, local, offline)
|
||||||
|
</span>
|
||||||
|
<code>{"{width}"}</code>
|
||||||
|
<code>{"{height}"}</code>
|
||||||
|
<code>{"{fps}"}</code>
|
||||||
|
<code>{"{duration}"}</code>
|
||||||
|
<code>{"{file size}"}</code>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="format-hint">
|
<p className="format-hint">
|
||||||
Match scans filenames locally for season/episode or movie title/year patterns, plus release-tag text
|
Match scans filenames locally for season/episode or movie title/year patterns, plus release-tag text
|
||||||
like resolution, source (BluRay/HDTV/WEB-DL/…), video/audio codec, and release group — none of this
|
like resolution, source (BluRay/HDTV/WEB-DL/…), video/audio codec, and release group — none of this
|
||||||
needs a network call, since it's all parsed straight from the filename itself (Renamr has no video/audio
|
needs a network call, since it's all parsed straight from the filename itself. Alongside that, Match also
|
||||||
file prober, so a tag that isn't in the filename text just won't be available). Fetch Data resolves the
|
probes each file's own container header (no ffmpeg/MediaInfo needed) for its real resolution, frame
|
||||||
season/episode or movie guess against real TMDB metadata for the real title, episode title, and
|
rate, duration, and codecs — a filename tag wins whenever a file has both, but <code>{"{resolution}"}</code>,{" "}
|
||||||
air/release date — needs a free TMDB API token set in <strong>TMDB Settings</strong> first. A batch can
|
<code>{"{video codec}"}</code>, and <code>{"{audio codec}"}</code> still fill in from the real file when
|
||||||
freely mix TV and movie files — each file only fills in the fields that apply to it. FileBot's own short
|
the filename doesn't carry that tag, and <code>{"{width}"}</code>/<code>{"{height}"}</code>/
|
||||||
|
<code>{"{fps}"}</code>/<code>{"{duration}"}</code>/<code>{"{file size}"}</code> are only ever available
|
||||||
|
this way. Fetch Data resolves the
|
||||||
|
season/episode or movie guess against real online metadata for the real title, episode title, and
|
||||||
|
air/release date. TV episodes can come from TMDB, TVmaze, or OMDb, and movies from TMDB or OMDb — pick
|
||||||
|
each independently in <strong>Fetch Data Settings</strong> (TVmaze is free and needs no credential at
|
||||||
|
all; TMDB and OMDb each need their own free API credential set there first). A batch can
|
||||||
|
freely mix TV and movie files, even against different sources — each file only fills in the fields that apply to it. FileBot's own short
|
||||||
field names (<code>{"{n}"}</code>, <code>{"{s00e00}"}</code>, <code>{"{t}"}</code>, <code>{"{y}"}</code>,
|
field names (<code>{"{n}"}</code>, <code>{"{s00e00}"}</code>, <code>{"{t}"}</code>, <code>{"{y}"}</code>,
|
||||||
<code>{"{vf}"}</code>, <code>{"{vc}"}</code>, <code>{"{ac}"}</code>, <code>{"{group}"}</code>, etc.) still
|
<code>{"{vf}"}</code>, <code>{"{vc}"}</code>, <code>{"{ac}"}</code>, <code>{"{group}"}</code>, etc.) still
|
||||||
work too, side by side with the names above.
|
work too, side by side with the names above.
|
||||||
@@ -1431,18 +1657,53 @@ export default function RenamePanel() {
|
|||||||
{settingsOpen && (
|
{settingsOpen && (
|
||||||
<div className="modal-overlay" onClick={() => !savingSettings && setSettingsOpen(false)}>
|
<div className="modal-overlay" onClick={() => !savingSettings && setSettingsOpen(false)}>
|
||||||
<div className="modal-card modal-card-compact" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-card modal-card-compact" onClick={(e) => e.stopPropagation()}>
|
||||||
<h2 className="modal-title">TMDB Settings</h2>
|
<h2 className="modal-title">Fetch Data Settings</h2>
|
||||||
|
<span className="modal-field-label">TV episode source</span>
|
||||||
|
<div className="action-segmented" role="radiogroup" aria-label="Which source Fetch Data uses for TV episodes">
|
||||||
|
{(["tmdb", "tvmaze", "omdb"] as const).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
className={`action-segmented-option${tvSource === s ? " selected" : ""}`}
|
||||||
|
onClick={() => saveTvSource(s)}
|
||||||
|
>
|
||||||
|
{TV_SOURCE_LABELS[s].button}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="modal-subtitle">{TV_SOURCE_LABELS[tvSource].description}</p>
|
||||||
|
|
||||||
|
<span className="modal-field-label">Movie source</span>
|
||||||
|
<div className="action-segmented" role="radiogroup" aria-label="Which source Fetch Data uses for movies">
|
||||||
|
{(["tmdb", "omdb"] as const).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
className={`action-segmented-option${movieSource === s ? " selected" : ""}`}
|
||||||
|
onClick={() => saveMovieSource(s)}
|
||||||
|
>
|
||||||
|
{MOVIE_SOURCE_LABELS[s].button}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<p className="modal-subtitle">
|
<p className="modal-subtitle">
|
||||||
Fetch Data uses{" "}
|
{MOVIE_SOURCE_LABELS[movieSource].description} TVmaze isn't an option here — it has no movie catalog.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="modal-subtitle">
|
||||||
|
Get a free <strong>API Read Access Token</strong> from your{" "}
|
||||||
<a href="https://www.themoviedb.org/" target="_blank" rel="noreferrer">
|
<a href="https://www.themoviedb.org/" target="_blank" rel="noreferrer">
|
||||||
TheMovieDB
|
TMDB
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
to resolve local guesses into real episode titles and air dates. Get a free{" "}
|
account under Settings → API, and/or a free API key from{" "}
|
||||||
<strong>API Read Access Token</strong> from your TMDB account under Settings → API, then paste it below.
|
<a href="https://www.omdbapi.com/apikey.aspx" target="_blank" rel="noreferrer">
|
||||||
The token is stored locally on this machine and never leaves it except to call TMDB directly.
|
OMDb
|
||||||
|
</a>
|
||||||
|
, then paste whichever one(s) you need below. Each credential is stored locally on this machine and
|
||||||
|
never leaves it except to call that source directly.
|
||||||
</p>
|
</p>
|
||||||
<label htmlFor="tmdb-token" className="modal-field-label">
|
<label htmlFor="tmdb-token" className="modal-field-label">
|
||||||
API Read Access Token
|
TMDB API Read Access Token
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="tmdb-token"
|
id="tmdb-token"
|
||||||
@@ -1454,22 +1715,44 @@ export default function RenamePanel() {
|
|||||||
onChange={(e) => setTmdbTokenInput(e.target.value)}
|
onChange={(e) => setTmdbTokenInput(e.target.value)}
|
||||||
placeholder={hasTmdbToken ? "•••••••••• (already saved — enter a new token to replace it)" : "Paste your TMDB token here"}
|
placeholder={hasTmdbToken ? "•••••••••• (already saved — enter a new token to replace it)" : "Paste your TMDB token here"}
|
||||||
/>
|
/>
|
||||||
<div className="modal-actions modal-actions-split">
|
{hasTmdbToken && (
|
||||||
{hasTmdbToken ? (
|
<button
|
||||||
<button className="action-button" onClick={clearTmdbToken} disabled={savingSettings}>
|
className="action-button modal-inline-remove"
|
||||||
Remove Token
|
onClick={clearTmdbToken}
|
||||||
</button>
|
disabled={savingSettings}
|
||||||
) : (
|
>
|
||||||
<span />
|
Remove TMDB Token
|
||||||
)}
|
</button>
|
||||||
<div className="modal-actions">
|
)}
|
||||||
<button className="action-button" onClick={() => setSettingsOpen(false)} disabled={savingSettings}>
|
<label htmlFor="omdb-key" className="modal-field-label">
|
||||||
Cancel
|
OMDb API Key
|
||||||
</button>
|
</label>
|
||||||
<button className="action-button primary" onClick={saveTmdbToken} disabled={savingSettings}>
|
<input
|
||||||
{savingSettings ? "Saving…" : "Save"}
|
id="omdb-key"
|
||||||
</button>
|
className="modal-text-input"
|
||||||
</div>
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
value={omdbKeyInput}
|
||||||
|
onChange={(e) => setOmdbKeyInput(e.target.value)}
|
||||||
|
placeholder={hasOmdbKey ? "•••••••••• (already saved — enter a new key to replace it)" : "Paste your OMDb key here"}
|
||||||
|
/>
|
||||||
|
{hasOmdbKey && (
|
||||||
|
<button
|
||||||
|
className="action-button modal-inline-remove"
|
||||||
|
onClick={clearOmdbApiKey}
|
||||||
|
disabled={savingSettings}
|
||||||
|
>
|
||||||
|
Remove OMDb Key
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="action-button" onClick={() => setSettingsOpen(false)} disabled={savingSettings}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="action-button primary" onClick={saveCredentials} disabled={savingSettings}>
|
||||||
|
{savingSettings ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
.sfv-panel {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfv-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--surface-deep);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfv-loaded-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfv-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfv-results {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfv-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--surface-deep);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfv-row-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfv-row-checksum {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--aurora-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfv-row-checksum-muted {
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfv-badge {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfv-badge-ok {
|
||||||
|
background: rgba(52, 211, 153, 0.16);
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfv-badge-mismatch {
|
||||||
|
background: rgba(248, 113, 113, 0.16);
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfv-badge-missing {
|
||||||
|
background: rgba(250, 204, 21, 0.16);
|
||||||
|
color: #facc15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfv-empty-note {
|
||||||
|
color: var(--muted-text);
|
||||||
|
font-size: 12.5px;
|
||||||
|
padding: 8px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sfv-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--aurora-cyan);
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
interface FileEntry {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
ext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChecksumKind = "crc32" | "md5" | "sha1";
|
||||||
|
|
||||||
|
const KIND_OPTIONS: { value: ChecksumKind; label: string }[] = [
|
||||||
|
{ value: "crc32", label: "CRC32 (.sfv)" },
|
||||||
|
{ value: "md5", label: "MD5" },
|
||||||
|
{ value: "sha1", label: "SHA-1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ChecksumOutcome {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
checksum: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerifyStatus = "ok" | "mismatch" | "missing";
|
||||||
|
|
||||||
|
interface VerifyResult {
|
||||||
|
fileName: string;
|
||||||
|
expected: string;
|
||||||
|
actual: string | null;
|
||||||
|
status: VerifyStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VerifyOutcome {
|
||||||
|
baseDir: string;
|
||||||
|
results: VerifyResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<VerifyStatus, string> = {
|
||||||
|
ok: "OK",
|
||||||
|
mismatch: "MISMATCH",
|
||||||
|
missing: "MISSING",
|
||||||
|
};
|
||||||
|
|
||||||
|
function defaultBaseName(files: FileEntry[]): string {
|
||||||
|
if (files.length === 0) return "checksums";
|
||||||
|
const dir = files[0].path.replace(/[\\/][^\\/]*$/, "");
|
||||||
|
const folderName = dir.split(/[\\/]/).pop();
|
||||||
|
return folderName && folderName.trim() ? folderName : "checksums";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SfvPanel() {
|
||||||
|
const [files, setFiles] = useState<FileEntry[]>([]);
|
||||||
|
const [kind, setKind] = useState<ChecksumKind>("crc32");
|
||||||
|
const [computing, setComputing] = useState(false);
|
||||||
|
const [computeResults, setComputeResults] = useState<ChecksumOutcome[] | null>(null);
|
||||||
|
const [verifyOutcome, setVerifyOutcome] = useState<VerifyOutcome | null>(null);
|
||||||
|
const [verifying, setVerifying] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
const picked = await invoke<FileEntry[]>("pick_files");
|
||||||
|
if (picked.length === 0) return;
|
||||||
|
setFiles(picked);
|
||||||
|
setComputeResults(null);
|
||||||
|
setVerifyOutcome(null);
|
||||||
|
setStatus(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCompute() {
|
||||||
|
if (files.length === 0) {
|
||||||
|
setStatus("Load files first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setComputing(true);
|
||||||
|
setVerifyOutcome(null);
|
||||||
|
try {
|
||||||
|
const results = await invoke<ChecksumOutcome[]>("compute_checksums", {
|
||||||
|
paths: files.map((f) => f.path),
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
setComputeResults(results);
|
||||||
|
const failed = results.filter((r) => r.error).length;
|
||||||
|
setStatus(failed > 0 ? `Computed ${results.length - failed} of ${results.length} (${failed} failed).` : `Computed ${results.length} checksum${results.length === 1 ? "" : "s"}.`);
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(String(err));
|
||||||
|
} finally {
|
||||||
|
setComputing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveChecksumFile() {
|
||||||
|
if (!computeResults || computeResults.length === 0) {
|
||||||
|
setStatus("Compute checksums first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = computeResults.filter((r) => r.checksum).map((r) => ({ name: r.name, checksum: r.checksum as string }));
|
||||||
|
if (rows.length === 0) {
|
||||||
|
setStatus("No successfully computed checksums to save.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const savedPath = await invoke<string | null>("save_checksum_file", {
|
||||||
|
defaultName: defaultBaseName(files),
|
||||||
|
rows,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
setStatus(savedPath ? `Saved to ${savedPath}` : "Save cancelled.");
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(String(err));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyChecksumFile() {
|
||||||
|
const picked = await invoke<FileEntry[]>("pick_files");
|
||||||
|
if (picked.length === 0) return;
|
||||||
|
const checksumFile = picked[0];
|
||||||
|
setVerifying(true);
|
||||||
|
setComputeResults(null);
|
||||||
|
try {
|
||||||
|
const outcome = await invoke<VerifyOutcome>("verify_checksum_file", {
|
||||||
|
checksumFilePath: checksumFile.path,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
setVerifyOutcome(outcome);
|
||||||
|
const ok = outcome.results.filter((r) => r.status === "ok").length;
|
||||||
|
const mismatch = outcome.results.filter((r) => r.status === "mismatch").length;
|
||||||
|
const missing = outcome.results.filter((r) => r.status === "missing").length;
|
||||||
|
setStatus(`${ok} OK, ${mismatch} mismatch${mismatch === 1 ? "" : "es"}, ${missing} missing.`);
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(String(err));
|
||||||
|
} finally {
|
||||||
|
setVerifying(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sfv-panel">
|
||||||
|
<div className="sfv-toolbar">
|
||||||
|
<button className="action-button" onClick={loadFiles}>
|
||||||
|
Load Files…
|
||||||
|
</button>
|
||||||
|
<span className="sfv-loaded-count">{files.length > 0 ? `${files.length} file${files.length === 1 ? "" : "s"} loaded` : "No files loaded"}</span>
|
||||||
|
<div className="action-segmented" role="radiogroup" aria-label="Checksum type">
|
||||||
|
{KIND_OPTIONS.map((opt) => (
|
||||||
|
<button key={opt.value} className={kind === opt.value ? "selected" : ""} onClick={() => setKind(opt.value)}>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sfv-actions">
|
||||||
|
<button className="action-button primary" onClick={runCompute} disabled={computing || files.length === 0}>
|
||||||
|
{computing ? "Computing…" : "Compute"}
|
||||||
|
</button>
|
||||||
|
<button className="action-button" onClick={saveChecksumFile} disabled={saving || !computeResults}>
|
||||||
|
{saving ? "Saving…" : "Save Checksum File…"}
|
||||||
|
</button>
|
||||||
|
<button className="action-button" onClick={verifyChecksumFile} disabled={verifying}>
|
||||||
|
{verifying ? "Verifying…" : "Verify Checksum File…"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sfv-results">
|
||||||
|
{!computeResults && !verifyOutcome && <div className="sfv-empty-note">Load files and Compute, or pick an existing checksum file to Verify.</div>}
|
||||||
|
|
||||||
|
{computeResults &&
|
||||||
|
computeResults.map((r) => (
|
||||||
|
<div className="sfv-row" key={r.path}>
|
||||||
|
<span className="sfv-row-name">{r.name}</span>
|
||||||
|
{r.checksum ? <span className="sfv-row-checksum">{r.checksum}</span> : <span className="sfv-badge sfv-badge-missing">ERROR</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{verifyOutcome &&
|
||||||
|
verifyOutcome.results.map((r) => (
|
||||||
|
<div className="sfv-row" key={r.fileName}>
|
||||||
|
<span className="sfv-row-name">{r.fileName}</span>
|
||||||
|
{r.actual && r.status === "mismatch" && (
|
||||||
|
<span className="sfv-row-checksum sfv-row-checksum-muted">
|
||||||
|
{r.actual} ≠ {r.expected}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={`sfv-badge sfv-badge-${r.status}`}>{STATUS_LABELS[r.status]}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status && <div className="sfv-status">{status}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ export default function Sidebar({
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
<span className="sidebar-wordmark">Renamr</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>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
.subtitles-panel {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles-loaded-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--muted-text);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles-settings-button {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles-search-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--surface-deep);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles-search-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles-search-row-inline {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles-search-row-inline > div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles-search-row-inline > div:first-child,
|
||||||
|
.subtitles-search-row-inline > div:nth-child(2) {
|
||||||
|
flex: 0 0 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles-search-row-inline > div:nth-child(3) {
|
||||||
|
flex: 0 0 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles-search-row-inline > div:last-child {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles-results {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles-result-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--surface-deep);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles-result-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles-result-release {
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles-result-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted-text);
|
||||||
|
background: var(--surface-alt);
|
||||||
|
border: 1px solid var(--surface-deep);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles-badge-strong {
|
||||||
|
color: var(--aurora-green);
|
||||||
|
border-color: var(--aurora-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles-result-meta {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles-empty-note {
|
||||||
|
color: var(--muted-text);
|
||||||
|
font-size: 12.5px;
|
||||||
|
padding: 8px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--aurora-cyan);
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
interface FileEntry {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
ext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EpisodeGuess {
|
||||||
|
seriesName: string;
|
||||||
|
season: number;
|
||||||
|
episode: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MovieGuess {
|
||||||
|
title: string;
|
||||||
|
year: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubtitleResult {
|
||||||
|
fileId: number;
|
||||||
|
release: string;
|
||||||
|
language: string;
|
||||||
|
downloadCount: number;
|
||||||
|
hearingImpaired: boolean;
|
||||||
|
hd: boolean;
|
||||||
|
moviehashMatch: boolean;
|
||||||
|
uploader: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubtitleDownloadOutcome {
|
||||||
|
savedPath: string;
|
||||||
|
remainingDownloads: number;
|
||||||
|
allowedDownloads: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LANGUAGE_OPTIONS: { code: string; label: string }[] = [
|
||||||
|
{ code: "en", label: "English" },
|
||||||
|
{ code: "es", label: "Spanish" },
|
||||||
|
{ code: "fr", label: "French" },
|
||||||
|
{ code: "de", label: "German" },
|
||||||
|
{ code: "it", label: "Italian" },
|
||||||
|
{ code: "pt", label: "Portuguese" },
|
||||||
|
{ code: "pt-BR", label: "Portuguese (Brazil)" },
|
||||||
|
{ code: "nl", label: "Dutch" },
|
||||||
|
{ code: "sv", label: "Swedish" },
|
||||||
|
{ code: "pl", label: "Polish" },
|
||||||
|
{ code: "ru", label: "Russian" },
|
||||||
|
{ code: "ja", label: "Japanese" },
|
||||||
|
{ code: "ko", label: "Korean" },
|
||||||
|
{ code: "zh-CN", label: "Chinese (Simplified)" },
|
||||||
|
{ code: "ar", label: "Arabic" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SubtitlesPanel() {
|
||||||
|
const [videoPath, setVideoPath] = useState<string | null>(null);
|
||||||
|
const [videoName, setVideoName] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [season, setSeason] = useState("");
|
||||||
|
const [episode, setEpisode] = useState("");
|
||||||
|
const [year, setYear] = useState("");
|
||||||
|
const [language, setLanguage] = useState("en");
|
||||||
|
|
||||||
|
const [results, setResults] = useState<SubtitleResult[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [searched, setSearched] = useState(false);
|
||||||
|
const [downloadingId, setDownloadingId] = useState<number | null>(null);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [savingSettings, setSavingSettings] = useState(false);
|
||||||
|
const [hasApiKey, setHasApiKey] = useState<boolean | null>(null);
|
||||||
|
const [hasUsername, setHasUsername] = useState<boolean | null>(null);
|
||||||
|
const [hasPassword, setHasPassword] = useState<boolean | null>(null);
|
||||||
|
const [apiKeyInput, setApiKeyInput] = useState("");
|
||||||
|
const [usernameInput, setUsernameInput] = useState("");
|
||||||
|
const [passwordInput, setPasswordInput] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invoke<string | null>("get_opensubtitles_api_key")
|
||||||
|
.then((key) => setHasApiKey(!!key))
|
||||||
|
.catch(() => setHasApiKey(false));
|
||||||
|
invoke<string | null>("get_opensubtitles_username")
|
||||||
|
.then((name) => setHasUsername(!!name))
|
||||||
|
.catch(() => setHasUsername(false));
|
||||||
|
invoke<boolean>("has_opensubtitles_password")
|
||||||
|
.then(setHasPassword)
|
||||||
|
.catch(() => setHasPassword(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showStatus = (message: string) => {
|
||||||
|
setStatus(message);
|
||||||
|
window.setTimeout(() => setStatus((current) => (current === message ? null : current)), 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadVideo = async () => {
|
||||||
|
try {
|
||||||
|
const entries = await invoke<FileEntry[]>("pick_files");
|
||||||
|
const entry = entries?.[0];
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
setVideoPath(entry.path);
|
||||||
|
setVideoName(entry.ext ? `${entry.name}.${entry.ext}` : entry.name);
|
||||||
|
setResults([]);
|
||||||
|
setSearched(false);
|
||||||
|
|
||||||
|
const filename = entry.ext ? `${entry.name}.${entry.ext}` : entry.name;
|
||||||
|
const episodeGuess = await invoke<EpisodeGuess | null>("guess_episode", { filename }).catch(() => null);
|
||||||
|
if (episodeGuess) {
|
||||||
|
setQuery(episodeGuess.seriesName);
|
||||||
|
setSeason(String(episodeGuess.season));
|
||||||
|
setEpisode(String(episodeGuess.episode));
|
||||||
|
setYear("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const movieGuess = await invoke<MovieGuess | null>("guess_movie", { filename }).catch(() => null);
|
||||||
|
if (movieGuess) {
|
||||||
|
setQuery(movieGuess.title);
|
||||||
|
setSeason("");
|
||||||
|
setEpisode("");
|
||||||
|
setYear(movieGuess.year ? String(movieGuess.year) : "");
|
||||||
|
} else {
|
||||||
|
setQuery(entry.name);
|
||||||
|
setSeason("");
|
||||||
|
setEpisode("");
|
||||||
|
setYear("");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showStatus(`Couldn't load a file: ${err}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runSearch = async () => {
|
||||||
|
const trimmedQuery = query.trim();
|
||||||
|
if (!trimmedQuery) {
|
||||||
|
showStatus("Enter a title to search for first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearching(true);
|
||||||
|
setSearched(false);
|
||||||
|
try {
|
||||||
|
const found = await invoke<SubtitleResult[]>("search_subtitles", {
|
||||||
|
query: trimmedQuery,
|
||||||
|
season: season.trim() ? Number(season.trim()) : null,
|
||||||
|
episode: episode.trim() ? Number(episode.trim()) : null,
|
||||||
|
year: year.trim() ? Number(year.trim()) : null,
|
||||||
|
language,
|
||||||
|
});
|
||||||
|
setResults(found);
|
||||||
|
setSearched(true);
|
||||||
|
if (found.length === 0) {
|
||||||
|
showStatus("No subtitles found for that search.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setResults([]);
|
||||||
|
setSearched(true);
|
||||||
|
const message = String(err);
|
||||||
|
showStatus(message);
|
||||||
|
if (message.toLowerCase().includes("settings")) {
|
||||||
|
setSettingsOpen(true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadResult = async (result: SubtitleResult) => {
|
||||||
|
if (!videoPath) {
|
||||||
|
showStatus("Load a video file first — a subtitle needs somewhere to be saved next to.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDownloadingId(result.fileId);
|
||||||
|
try {
|
||||||
|
const outcome = await invoke<SubtitleDownloadOutcome>("download_subtitle", {
|
||||||
|
fileId: result.fileId,
|
||||||
|
videoPath,
|
||||||
|
language: result.language || language,
|
||||||
|
});
|
||||||
|
showStatus(
|
||||||
|
`Saved ${outcome.savedPath.split("/").pop()} (${outcome.remainingDownloads}/${outcome.allowedDownloads} OpenSubtitles downloads left today).`
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message = String(err);
|
||||||
|
showStatus(message);
|
||||||
|
if (message.toLowerCase().includes("settings")) {
|
||||||
|
setSettingsOpen(true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setDownloadingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSettings = () => {
|
||||||
|
setApiKeyInput("");
|
||||||
|
setUsernameInput("");
|
||||||
|
setPasswordInput("");
|
||||||
|
setSettingsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSettings = async () => {
|
||||||
|
const trimmedKey = apiKeyInput.trim();
|
||||||
|
const trimmedUsername = usernameInput.trim();
|
||||||
|
const trimmedPassword = passwordInput.trim();
|
||||||
|
if (!trimmedKey && !trimmedUsername && !trimmedPassword) {
|
||||||
|
setSettingsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavingSettings(true);
|
||||||
|
try {
|
||||||
|
if (trimmedKey) {
|
||||||
|
await invoke("save_opensubtitles_api_key", { key: trimmedKey });
|
||||||
|
setHasApiKey(true);
|
||||||
|
}
|
||||||
|
if (trimmedUsername || trimmedPassword) {
|
||||||
|
await invoke("save_opensubtitles_login", { username: usernameInput, password: passwordInput });
|
||||||
|
if (trimmedUsername) setHasUsername(true);
|
||||||
|
if (trimmedPassword) setHasPassword(true);
|
||||||
|
}
|
||||||
|
setApiKeyInput("");
|
||||||
|
setUsernameInput("");
|
||||||
|
setPasswordInput("");
|
||||||
|
setSettingsOpen(false);
|
||||||
|
showStatus("Subtitle settings saved.");
|
||||||
|
} catch (err) {
|
||||||
|
showStatus(`Couldn't save: ${err}`);
|
||||||
|
} finally {
|
||||||
|
setSavingSettings(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearApiKey = async () => {
|
||||||
|
setSavingSettings(true);
|
||||||
|
try {
|
||||||
|
await invoke("save_opensubtitles_api_key", { key: "" });
|
||||||
|
setHasApiKey(false);
|
||||||
|
showStatus("OpenSubtitles API key removed.");
|
||||||
|
} finally {
|
||||||
|
setSavingSettings(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearLogin = async () => {
|
||||||
|
setSavingSettings(true);
|
||||||
|
try {
|
||||||
|
await invoke("save_opensubtitles_login", { username: "", password: "" });
|
||||||
|
setHasUsername(false);
|
||||||
|
setHasPassword(false);
|
||||||
|
showStatus("OpenSubtitles login removed.");
|
||||||
|
} finally {
|
||||||
|
setSavingSettings(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="subtitles-panel">
|
||||||
|
<div className="subtitles-toolbar">
|
||||||
|
<button className="action-button" onClick={loadVideo}>
|
||||||
|
Load Video File…
|
||||||
|
</button>
|
||||||
|
<span className="subtitles-loaded-name">{videoName ?? "No video file loaded"}</span>
|
||||||
|
<button className="action-button subtitles-settings-button" onClick={openSettings}>
|
||||||
|
Subtitle Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="subtitles-search-form">
|
||||||
|
<div className="subtitles-search-row">
|
||||||
|
<label className="modal-field-label" htmlFor="subtitles-query">
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="subtitles-query"
|
||||||
|
className="modal-text-input"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Show or movie title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="subtitles-search-row subtitles-search-row-inline">
|
||||||
|
<div>
|
||||||
|
<label className="modal-field-label" htmlFor="subtitles-season">
|
||||||
|
Season
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="subtitles-season"
|
||||||
|
className="modal-text-input"
|
||||||
|
value={season}
|
||||||
|
onChange={(e) => setSeason(e.target.value)}
|
||||||
|
placeholder="TV only"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="modal-field-label" htmlFor="subtitles-episode">
|
||||||
|
Episode
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="subtitles-episode"
|
||||||
|
className="modal-text-input"
|
||||||
|
value={episode}
|
||||||
|
onChange={(e) => setEpisode(e.target.value)}
|
||||||
|
placeholder="TV only"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="modal-field-label" htmlFor="subtitles-year">
|
||||||
|
Year
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="subtitles-year"
|
||||||
|
className="modal-text-input"
|
||||||
|
value={year}
|
||||||
|
onChange={(e) => setYear(e.target.value)}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="modal-field-label" htmlFor="subtitles-language">
|
||||||
|
Language
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="subtitles-language"
|
||||||
|
className="modal-text-input"
|
||||||
|
value={language}
|
||||||
|
onChange={(e) => setLanguage(e.target.value)}
|
||||||
|
>
|
||||||
|
{LANGUAGE_OPTIONS.map((l) => (
|
||||||
|
<option key={l.code} value={l.code}>
|
||||||
|
{l.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="action-button primary" onClick={runSearch} disabled={searching}>
|
||||||
|
{searching ? "Searching…" : "Search Subtitles"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="subtitles-results">
|
||||||
|
{results.map((r) => (
|
||||||
|
<div className="subtitles-result-row" key={r.fileId}>
|
||||||
|
<div className="subtitles-result-main">
|
||||||
|
<span className="subtitles-result-release">{r.release}</span>
|
||||||
|
<span className="subtitles-result-badges">
|
||||||
|
<span className="subtitles-badge">{r.language}</span>
|
||||||
|
{r.hd && <span className="subtitles-badge">HD</span>}
|
||||||
|
{r.moviehashMatch && <span className="subtitles-badge subtitles-badge-strong">Hash match</span>}
|
||||||
|
{r.hearingImpaired && <span className="subtitles-badge">HI</span>}
|
||||||
|
</span>
|
||||||
|
<span className="subtitles-result-meta">
|
||||||
|
{r.uploader ? `by ${r.uploader} — ` : ""}
|
||||||
|
{r.downloadCount.toLocaleString()} downloads
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="action-button"
|
||||||
|
onClick={() => downloadResult(r)}
|
||||||
|
disabled={downloadingId === r.fileId}
|
||||||
|
>
|
||||||
|
{downloadingId === r.fileId ? "Downloading…" : "Download"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{searched && results.length === 0 && <p className="subtitles-empty-note">No subtitles found.</p>}
|
||||||
|
{!searched && (
|
||||||
|
<p className="subtitles-empty-note">
|
||||||
|
Load a video file (or just type a title) and search — OpenSubtitles searching needs a free API key,
|
||||||
|
downloading also needs your OpenSubtitles account login. See Subtitle Settings above.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status && <div className="subtitles-status">{status}</div>}
|
||||||
|
|
||||||
|
{settingsOpen && (
|
||||||
|
<div className="modal-overlay" onClick={() => !savingSettings && setSettingsOpen(false)}>
|
||||||
|
<div className="modal-card modal-card-compact" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2 className="modal-title">Subtitle Settings</h2>
|
||||||
|
<p className="modal-subtitle">
|
||||||
|
Get a free API key by creating an account at{" "}
|
||||||
|
<a href="https://www.opensubtitles.com/" target="_blank" rel="noreferrer">
|
||||||
|
OpenSubtitles
|
||||||
|
</a>{" "}
|
||||||
|
and registering an app under Account → API Consumers — needed just to search. Downloading a subtitle
|
||||||
|
additionally needs your own OpenSubtitles account login below, and is limited to a daily quota that
|
||||||
|
OpenSubtitles sets per account (shown after each download).
|
||||||
|
</p>
|
||||||
|
<label htmlFor="opensubtitles-key" className="modal-field-label">
|
||||||
|
OpenSubtitles API Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="opensubtitles-key"
|
||||||
|
className="modal-text-input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
value={apiKeyInput}
|
||||||
|
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||||
|
placeholder={hasApiKey ? "•••••••••• (already saved — enter a new key to replace it)" : "Paste your OpenSubtitles API key here"}
|
||||||
|
/>
|
||||||
|
{hasApiKey && (
|
||||||
|
<button className="action-button modal-inline-remove" onClick={clearApiKey} disabled={savingSettings}>
|
||||||
|
Remove API Key
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label htmlFor="opensubtitles-username" className="modal-field-label">
|
||||||
|
OpenSubtitles Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="opensubtitles-username"
|
||||||
|
className="modal-text-input"
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
value={usernameInput}
|
||||||
|
onChange={(e) => setUsernameInput(e.target.value)}
|
||||||
|
placeholder={hasUsername ? "already saved — enter a new username to replace it" : "Your OpenSubtitles username"}
|
||||||
|
/>
|
||||||
|
<label htmlFor="opensubtitles-password" className="modal-field-label">
|
||||||
|
OpenSubtitles Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="opensubtitles-password"
|
||||||
|
className="modal-text-input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
value={passwordInput}
|
||||||
|
onChange={(e) => setPasswordInput(e.target.value)}
|
||||||
|
placeholder={hasPassword ? "•••••••••• (already saved — enter a new password to replace it)" : "Your OpenSubtitles password"}
|
||||||
|
/>
|
||||||
|
{(hasUsername || hasPassword) && (
|
||||||
|
<button className="action-button modal-inline-remove" onClick={clearLogin} disabled={savingSettings}>
|
||||||
|
Remove Login
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="action-button" onClick={() => setSettingsOpen(false)} disabled={savingSettings}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="action-button primary" onClick={saveSettings} disabled={savingSettings}>
|
||||||
|
{savingSettings ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,11 @@ import "./App.css";
|
|||||||
import "./components/Sidebar.css";
|
import "./components/Sidebar.css";
|
||||||
import "./components/Header.css";
|
import "./components/Header.css";
|
||||||
import "./components/RenamePanel.css";
|
import "./components/RenamePanel.css";
|
||||||
|
import "./components/EpisodesPanel.css";
|
||||||
|
import "./components/SubtitlesPanel.css";
|
||||||
|
import "./components/SfvPanel.css";
|
||||||
|
import "./components/FilterPanel.css";
|
||||||
|
import "./components/ListPanel.css";
|
||||||
import "./components/PanelStub.css";
|
import "./components/PanelStub.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* Renamr "Aurora" theme — ported 1:1 (hex values) from the original FB-Mod fork's
|
/* Aurora Curator's UI theme — ported 1:1 (hex values) from the original FB-Mod fork's
|
||||||
AuroraTheme.java / HeaderPanel.java / MainFrame.java so the rewrite looks like a
|
AuroraTheme.java / HeaderPanel.java / MainFrame.java so the rewrite looks like a
|
||||||
continuation of that design rather than a random restyle. */
|
continuation of that design rather than a random restyle. (The app takes its name,
|
||||||
|
"Aurora Curator", from this same Aurora theme.) */
|
||||||
:root {
|
:root {
|
||||||
--dock: #08252b;
|
--dock: #08252b;
|
||||||
--dock-deep: #051115;
|
--dock-deep: #051115;
|
||||||
|
|||||||