10 Commits
Author SHA1 Message Date
Renamr Scaffold 1291ae086d Add real media-file probing (session 9, sixteenth delivery)
Wires real container-header probing into the Rename panel's Match step, the
first item on the porting roadmap: aurora-curator-core::probe reads actual
resolution, frame rate, duration, and video/audio codecs straight from a
file's own header (mp4 crate for MP4/M4V/MOV, matroska crate for MKV/WebM,
no ffmpeg/MediaInfo dependency), batched through a new probe_media_files
Tauri command. Probed values fill {resolution}/{video codec}/{audio codec}
only when a filename release tag hasn't already, and populate five bindings
with no filename-tag equivalent at all: {width}, {height}, {fps},
{duration}, {file size}. 8 new Rust unit tests against real ffmpeg-generated
fixture files (161 -> 169 total), verified end to end with a 15-check
Playwright script against npm run dev, and README updated (status
paragraph, project layout, a new "Real media-file probing" section, and
roadmap item 1 narrowed to what's still missing).
2026-08-29 18:07:54 +00:00
Renamr Scaffold 03768c398d Add List panel (session 9, fifteenth feature)
Renders a naming-scheme-style expression per loaded file via the same
render_expression engine the Rename panel's live preview uses, fed by
the same local guess_episode/guess_movie/guess_release_info matching
Filter already uses. Copy List and Export to File... (a new
export_text_list Tauri command - the only new backend surface this
feature needed) round it out. Closes out all six original sidebar
panels as real screens.
2026-08-29 17:28:38 +00:00
Renamr Scaffold bfcec9135a Add Filter panel: narrow a loaded batch by extension, kind, filename, and season
Fully local, and adds no new Rust/Tauri commands at all - reuses pick_files plus the
same guess_episode/guess_movie/guess_release_info local matchers "Match" already calls
in the Rename panel, just applied to a second, independently-loaded file list and
rendered as toggleable filter criteria instead of a rename preview.

New src/components/FilterPanel.tsx/.css: Load Files scans the batch immediately,
toggleable chips appear for every extension and media kind actually present, a
filename-contains text field and a season number field (TV only) narrow further, and
Copy File Paths exports whatever currently matches to the clipboard.

Verified with a 21-check Playwright script against npm run dev (chip toggling, the
filename/season filters, the no-match empty state, and the clipboard export).
2026-08-29 17:01:15 +00:00
Renamr Scaffold 316dbfc1ff Add SFV panel: compute/verify CRC32/MD5/SHA1 checksums, .sfv/.md5/.sha1 sidecar files
Fully local feature - no network access, no credentials. New aurora-curator-core::sfv
module streams file hashing in 64KB chunks and handles both the traditional .sfv
format (CRC32, "name checksum" layout) and the GNU coreutils md5sum/sha1sum sidecar
convention (.md5/.sha1, "checksum  name" layout), dispatched on a shared ChecksumKind
enum. New src-tauri/src/sfv.rs wraps it in three Tauri commands - compute_checksums
(per-file error isolation), save_checksum_file (native Save dialog), and
verify_checksum_file (resolves each entry against the checksum file's own parent
directory, reports OK/Mismatch/Missing per entry). New SfvPanel.tsx/.css wires it all
into the sidebar's SFV screen - load files, pick a checksum type, Compute, Save, or
Verify against an existing checksum file.

14 new core-crate tests + 1 new src-tauri test, all passing (workspace: 160/160).
Verified end-to-end with a 22-check Playwright script against npm run dev.
2026-08-29 15:59:05 +00:00
Renamr Scaffold ed26391651 Add Episodes panel: search a series, browse a season's episode list
New src-tauri/src/episodes.rs exposes browse_episodes, reusing the
existing AnyTvClient::fetch_season machinery Fetch Data already built
(via a build_tv_client helper factored out of metadata.rs) so this
panel needs no credentials of its own - it reads whatever TV source
(TMDB/TVmaze/OMDb) is already configured in Fetch Data Settings.

New EpisodesPanel.tsx/.css: series name + season stepper, an episode
list (number/title/air date), and a Copy list button that copies the
season as plain text. Wired into App.tsx's sidebar routing as the
second stub panel (after Subtitles) to become a real screen.

145/145 Rust tests passing (+1), npm run build clean, 15/15 Playwright
checks against npm run dev (mocked Tauri bridge) covering search,
the empty-season case, a not-found series, the season stepper, Copy
list, and the blank-query guard.
2026-08-29 15:35:27 +00:00
Renamr Scaffold b47d28375b Add OpenSubtitles subtitle search + download (new Subtitles panel)
New web client (crates/aurora-curator-core/src/web/opensubtitles.rs, 9 tests):
search, login, download, and fetch-subtitle-text against OpenSubtitles' REST
API. Search only needs a free app-level API key; download additionally needs
the end user's own OpenSubtitles account login (username/password -> JWT),
since downloads are quota-limited per account. Settings gained three new
fields (opensubtitles_api_key/username/password) following the existing
TMDB/OMDb credential pattern, with one deliberate exception: the password is
never returned to the frontend, only whether one is saved
(has_opensubtitles_password), since it's a real account password rather than
an API token.

New src-tauri/src/subtitles.rs wraps search/download as Tauri commands and
writes a downloaded subtitle right next to its video file
(Show.S01E01.mkv -> Show.S01E01.en.srt).

New src/components/SubtitlesPanel.tsx is the first real screen behind a
sidebar entry besides Rename (previously a stub) - load a video, auto-fill
the search form from the same local guess_episode/guess_movie Match uses,
search, and download with live remaining/allowed quota shown per result.

TheTVDB was considered instead but deliberately skipped for now: its v4 API
requires either a paid commercial license or a per-end-user paid TheTVDB
subscription just to log in, unlike every other source in this app.

144 tests passing (workspace). Updated README (status paragraph, project
layout, new "Subtitles" section, roadmap items 5/6/7).
2026-08-29 14:08:17 +00:00
Renamr Scaffold c158befa83 Add OMDb as a datasource: TV episodes (third option alongside TMDB/TVmaze) and movies (new second option alongside TMDB)
Movies get their own independent metadata-source setting for the first
time (previously hardcoded to TMDB) so the GUI's Fetch Data Settings now
has two separate pickers - TV episode source (TMDB/TVmaze/OMDb) and
movie source (TMDB/OMDb) - and a batch can freely mix sources across its
TV and movie halves. The -rename CLI's --db flag gained omdb alongside
tmdb/tvmaze, with movies now looked up online for both tmdb and omdb.
2026-08-29 12:25:45 +00:00
Renamr ScaffoldandClaude Sonnet 5 22a3da9f6f Add CLI flag surface: -rename/--format/--db/--action/--conflict/-non-strict/-h
Ports FileBot's net.filebot.cli flag surface so Aurora Curator can be scripted,
not just driven through the GUI. Argument parsing and file-expansion are pure
Rust in aurora-curator-core::cli (14 unit tests, no Tauri/network dependency);
orchestration lives in src-tauri/src/cli.rs, which reuses the exact same
binding vocabulary the GUI's RenamePanel.tsx builds client-side and the exact
same TMDB/TVmaze season-caching lookup fetch_metadata already uses, so a
naming scheme renders identically whether it's typed into the app or passed
via --format.

main.rs branches to CLI mode before any Tauri App/window is built at all,
using tauri::async_runtime::block_on to drive the async metadata lookups
(confirmed safe standalone by reading Tauri's own async_runtime.rs) and a
hand-rolled settings::cli_tmdb_token() that replicates Tauri's own
app_config_dir() computation (dirs::config_dir().join(identifier), pinned
against tauri.conf.json by a new test) so a token saved through the GUI's
Fetch Data Settings is found from the CLI path too - the CLI still never
accepts a token as an argument.

Deliberately not ported: --output <dir> (the rename engine's safety
invariant against escaping the source directory tree would need to be
relaxed) and -non-strict is accepted as a no-op (local matching has no
confidence score to loosen).

16 new tests in src-tauri (10 in cli.rs, 1 pinning settings::
APP_CONFIG_IDENTIFIER) plus 14 in aurora-curator-core's cli module - full
workspace suite is 113 passing. README updated with a new "Command-line
usage" section and roadmap item 6 marked done for the CLI surface.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QLbx8ov7zMcdDPEqQm4Y98
2026-08-29 11:30:51 +00:00
Renamr Scaffold 5763c6313b Add TVmaze as a second, user-selectable TV metadata source for Fetch Data
TV episodes can now resolve against either TMDB (default) or TVmaze - picked
via a segmented source picker in the renamed "Fetch Data Settings" modal.
TVmaze is free and needs no API token at all; movies still always resolve
against TMDB regardless of the choice, since TVmaze has no movie catalog.

- New TvmazeClient (crates/aurora-curator-core/src/web/tvmaze.rs): search +
  season/episode lookup against api.tvmaze.com, filtering out unnumbered
  specials rather than panicking on them (TVmaze returns every season's
  episodes in one flat list, unlike TMDB's per-season endpoint).
- New TvMetadataSource setting (settings.rs), persisted alongside the TMDB
  token, defaulting to Tmdb for schema-evolution safety.
- metadata.rs now branches TV fetches through an AnyTvClient wrapper so the
  existing per-(series, season) caching loop doesn't care which source
  produced a result; the movie-fetch path is untouched.
- Frontend: TMDB/TVmaze picker in Fetch Data Settings, persisted on change;
  Fetch Data's token gating now only requires a TMDB token when it's actually
  needed (movies always, TV only when TMDB is the chosen source) instead of
  unconditionally blocking on it.

cargo test --workspace: 88/88 passing (81 + 7 new TVmaze + 3 new settings).
npm run build passing. Playwright verification: 18/18 checks (picker renders
and persists across reload, TV Fetch Data succeeds on TVmaze with zero TMDB
token configured, movies still correctly demand a token even with TVmaze
selected, switching back to TMDB still works end-to-end).
2026-08-29 10:44:12 +00:00
Renamr Scaffold 7097b1ede7 Rebrand: Renamr -> Aurora Curator (new logo/app icon, renamed crate aurora-curator-core, package/identifier/window-title updates, README + in-app text) 2026-08-29 10:07:09 +00:00
72 changed files with 7567 additions and 333 deletions
Generated
+128 -28
View File
@@ -100,6 +100,40 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "autocfg"
version = "1.5.1"
@@ -148,6 +182,15 @@ dependencies = [
"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]]
name = "block-buffer"
version = "0.10.4"
@@ -1904,6 +1947,26 @@ dependencies = [
"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]]
name = "memchr"
version = "2.8.3"
@@ -1981,6 +2044,20 @@ dependencies = [
"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]]
name = "muda"
version = "0.19.3"
@@ -2041,12 +2118,52 @@ dependencies = [
"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]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "num-traits"
version = "0.2.19"
@@ -2770,34 +2887,6 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "reqwest"
version = "0.12.28"
@@ -3265,6 +3354,17 @@ dependencies = [
"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]]
name = "sha2"
version = "0.10.9"
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["crates/renamr-core", "src-tauri"]
members = ["crates/aurora-curator-core", "src-tauri"]
[workspace.package]
version = "0.1.0"
+492 -75
View File
@@ -1,4 +1,4 @@
# Renamr
# Aurora Curator
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.
@@ -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
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
movie title/year* detection ("Match") is working, real online metadata lookups against
TMDB ("Fetch Data") are working for both TV episodes and movies — including fuzzy
title disambiguation, so a search that returns several similarly-named results doesn't
just take TMDB's top-ranked hit — and the actual on-disk operation is wired up (with a
movie title/year* detection ("Match") is working, real online metadata lookups
("Fetch Data") are working for both TV episodes and movies — including fuzzy title
disambiguation, so a search that returns several similarly-named results doesn't just
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,
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/
@@ -26,12 +30,41 @@ TV episodes and movies
text straight out of the filename — resolution, source (BluRay/HDTV/WEB-DL/…),
video/audio codec, release group — so naming schemes can reference
`{resolution}`/`{source}`/`{video codec}`/`{audio codec}`/`{release group}` (or
FileBot's own `{vf}`/`{source}`/`{vc}`/`{ac}`/`{group}`) without a media prober; the
rest of FileBot's full media-metadata bindings (real probed video/audio stream info)
are not yet ported. The Align Up/Down buttons reorder the (shared) file list — the two
FileBot's own `{vf}`/`{source}`/`{vc}`/`{ac}`/`{group}`), and now also probes each
file's own container header for its real resolution, frame rate, duration, and
codecs — no ffmpeg/MediaInfo dependency required — filling in `{resolution}`/
`{video codec}`/`{audio codec}` whenever the filename itself didn't already carry
that tag, plus `{width}`/`{height}`/`{fps}`/`{duration}`/`{file size}`, which have no
filename-tag equivalent at all; see "Real media-file probing" below. The Align
Up/Down buttons reorder the (shared) file list — the two
panes always stay positionally paired — and up to 9 naming schemes can be saved as
presets, recalled with a click or a `1`-`9` keypress (persisted locally, same pattern as
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
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
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
(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
@@ -69,7 +102,7 @@ template syntax compatible with FileBot conventions, so existing automation most
keeps working.
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
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
@@ -79,7 +112,7 @@ translate directly.
### Naming scheme syntax
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
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
number} - {episode name}`) moves the file into that subfolder as part of renaming,
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
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
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
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
@@ -123,44 +156,148 @@ porting roadmap below for the full behavior of each.
## 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
(ported, `matching/`), plus filename-based release-tag parsing in
the same module (`guess_release_info` — resolution, source, video/
audio codec, release group), text-similarity scoring for
disambiguating search results (`similarity/`, partial
`EpisodeMetrics` port), a TMDB metadata client (`web/tmdb.rs` — TV
search + episode lookup
and movie search + lookup, both disambiguated via `similarity/`,
tested against a mocked HTTP server via `mockito`), the on-disk
`EpisodeMetrics` port), three web metadata clients — `web/tmdb.rs`
(TV search + episode lookup and movie search + lookup),
`web/tvmaze.rs` (TV search + episode lookup only — TVmaze has no
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,
Skip/Auto-number/Override conflict policies, can also organize
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
action produced each batch). Not yet ported: full media metadata
bindings, other web datasource clients (TVDB/AniDB/etc).
No Tauri dependency, so it builds and unit-tests standalone.
src-tauri/ Tauri app shell: exposes renamr-core as commands to the frontend
action produced each batch), pure `-rename` command-line
argument parsing/file expansion (`cli/` — no Tauri or network
dependency at all, see "Command-line usage" below), and real
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
movies, TMDB metadata fetch — `metadata.rs` for TV episodes,
plus a movie-fetch command alongside it — executing a rename
movies, metadata fetch — `metadata.rs` for TV episodes (against
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
`settings.rs` (TMDB token), `history.rs` (rename history/undo
log), and `presets.rs` (up to 9 saved naming schemes) — all three
stored as JSON in the OS app-config directory — app
`settings.rs` (TMDB token, OMDb API key, OpenSubtitles API key +
account login, and which TV *and* movie
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).
src/ React + TypeScript UI (Vite). Faithful recreation of the original
two-pane rename screen (Aurora theme) wired to real file loading,
drag-and-drop, live naming-scheme preview, local TV/movie
matching, TMDB fetch for both (behind a Settings modal for the
API token), a confirm-before-touching-disk modal for the actual
matching, real media-file probing (`probe_media_files`, batched
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
list reordering, and a Presets panel (plus `1`-`9` hotkeys) for
saved naming schemes.
scripts/gen_icons.py Regenerates src-tauri/icons/ (placeholder branding — swap the
colors/glyph in the script, or replace the icons directly, to
change the look).
saved naming schemes. `SubtitlesPanel.tsx` is a separate top-level
panel (its own Subtitle Settings modal, its own credential state)
reached from the sidebar's "Subtitles" entry — the first sidebar
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
@@ -172,7 +309,7 @@ toolchain; macOS/Windows need Xcode / the VS Build Tools + WebView2 respectively
```sh
# 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):
npm install
@@ -182,25 +319,257 @@ npm run tauri dev
npm run tauri build
```
Verified in the environment this was built in: `cargo test -p renamr-core` (all
passing, including the TMDB client tests — those run against a local mock HTTP server
via `mockito`, no live network or API key needed to build/test), `cargo check -p
renamr` (compiles clean against real Tauri + GTK/WebKit system libraries), and `npm run
build` (TypeScript type-checks, Vite bundles). The actual GUI window has **not** been
visually verified anywhere yet — that sandbox has no display. Run `npm run tauri dev`
on your machine to see and click through it for the first time.
Verified in the environment this was built in: `cargo test --workspace` (all 160
passing, including the TMDB, TVmaze, OMDb, and OpenSubtitles client tests — those run
against a local mock HTTP server via `mockito`, no live network or API key needed to
build/test), `cargo
check -p aurora-curator` (compiles clean against real Tauri + GTK/WebKit system
libraries), and `npm run build` (TypeScript type-checks, Vite bundles). The actual GUI
window has **not** been visually verified anywhere yet — that sandbox has no display.
Run `npm run tauri dev` on your machine to see and click through it for the first time.
### 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
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
**API Read Access Token** (not the older numeric "API Key" — Renamr uses the Bearer
token method TMDB documents as the default).
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
`api.themoviedb.org`.
TV episodes and movies each have their own independent source picker in the app (click
the key icon, "Fetch Data Settings", next to Fetch Data) — a batch can freely mix TV and
movie files resolved against two different sources at once:
- **[TVmaze](https://www.tvmaze.com/)** — TV only (it has no movie catalog). Free, no
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)
@@ -209,49 +578,79 @@ ported from, for anyone picking this up:
1. **Full naming-scheme bindings** — `net.filebot.format.MediaBindingBean` (1354 lines):
all the `{n}`, `{s00e00}`, `{airdate}`, `{vf}` (video format), etc. binding
properties. A filename-tag-based subset is now ported and wired to "Match"
(`guess_release_info` in `crates/renamr-core/src/matching`): `{vf}`/`{resolution}`,
properties. A filename-tag-based subset is ported and wired to "Match"
(`guess_release_info` in `crates/aurora-curator-core/src/matching`): `{vf}`/`{resolution}`,
`{source}`, `{vc}`/`{video codec}`, `{ac}`/`{audio codec}`, `{group}`/`{release
group}`. What's still missing is everything MediaBindingBean derives from actually
*probing* the media file (real video/audio stream resolution, duration, bitrate,
frame rate, file size/hash, etc.) — Renamr ships no ffprobe/MediaInfo dependency, so
those bindings only work when a release name happens to carry the info as text.
group}`. Real container probing (`crates/aurora-curator-core/src/probe`, see "Real
media-file probing" below) now covers the other half MediaBindingBean derives from
actually reading the file — real resolution/video codec/audio codec fill in
whenever the filename didn't already carry that tag, plus `{width}`/`{height}`/
`{fps}`/`{duration}`/`{file size}`, which have no filename-tag equivalent at all —
for the two container families it supports (MP4/M4V/MOV and MKV/WebM). Still
missing: probing for other containers (.avi, .wmv, ...), audio-only metadata
(bitrate, sample rate, channel layout), file hashing (`{crc32}` reuses the SFV
panel's own hasher today but isn't wired into naming-scheme bindings yet), and
wiring probed bindings into the List/Filter panels (currently Rename-panel-only).
2. **String/expression helper methods** — `net.filebot.format.ExpressionFormatMethods`
(636 lines): FileBot-specific Groovy string extensions like `.pad()`, `.sortName()`.
Only a small subset (`any`, `all_of`, `concat`) is ported so far
(`crates/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.
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
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
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
(`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
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
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
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.
4. **Media detection** — `net.filebot.media.MediaDetection` (1356 lines): ties file-name
parsing + matching together to auto-identify what a file is.
5. **Web datasource clients** — `net.filebot.web.*` (~40 files): TheMovieDB, TheTVDB,
AniDB, OMDb, TVmaze, OpenSubtitles, etc. TMDB is done for both TV and movies
(`crates/renamr-core/src/web/tmdb.rs`, `reqwest` + `serde_json`, Bearer-token auth) —
AniDB, OMDb, TVmaze, OpenSubtitles, etc. Three are done: TMDB, for both TV and movies
(`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
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
similarity-scoring pass described in item 3 above rather than blindly trusting
TMDB's top-ranked hit. All other sources (TheTVDB, AniDB, OMDb, TVmaze,
OpenSubtitles) are not yet started.
TVmaze, for TV only (`crates/aurora-curator-core/src/web/tvmaze.rs`, same `reqwest` +
`serde_json` shape, no auth at all — TVmaze has no movie catalog to search, and its
per-show episode endpoint returns every season in one flat list rather than TMDB's
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
`ArgumentBean` (415 lines, the full flag surface: `-rename`, `--db`, `--format`,
`--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
scheme like `{n}/{s00e00}` organizes files into subfolders, creating any missing
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
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
fixed skip-on-conflict policy no matter which policy the original rename used). Not
yet done: the CLI flag surface itself.
fixed skip-on-conflict policy no matter which policy the original rename used). The
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,
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
other five sidebar panels (Episodes/Subtitles/SFV/Filter/List) are still stub
screens. Align Up/Down is button-based (swaps the selected file with its neighbor),
not the original's drag-and-drop-within-a-list interaction.
Align Up/Down reordering, naming-scheme Presets with `1`-`9` hotkeys) is built, and so
are the Subtitles panel (search + download via OpenSubtitles, see "Subtitles" above),
the Episodes panel (series search + season browse with a Copy list export, no
credentials of its own, see "Episodes" above), 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
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
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
@@ -1,9 +1,9 @@
[package]
name = "renamr-core"
name = "aurora-curator-core"
version.workspace = true
edition.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]
# 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"] }
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]
# 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.
+475
View File
@@ -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.
//! `"{n} - {s00e00} - {t}"`
@@ -1,5 +1,5 @@
//! 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
//! 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
@@ -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
// by hand since - cheap, and `create_dir_all` is a no-op when it's already
// there.
@@ -175,7 +175,7 @@ mod tests {
fn temp_dir() -> std::path::PathBuf {
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();
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.
//! 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
//! parsing, web datasource clients, rename engine are not yet ported).
pub mod cli;
pub mod format;
pub mod history;
pub mod matching;
pub mod probe;
pub mod rename;
pub mod sfv;
pub mod similarity;
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
/// filename-parsing half of the original FileBot's `ReleaseInfo` class - the same
/// 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`/
/// `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
+318
View File
@@ -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
//! [`RenameAction`] and a [`ConflictPolicy`]) before calling [`execute_renames`]; every
//! 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
//! (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
@@ -47,7 +47,7 @@ pub struct RenamePlanItem {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
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.
Move,
/// 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
/// absolute-looking `/etc/passwd`-style path, since splitting it on `/` yields a
/// 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
/// computed name is almost certainly a mistake, not an intentional Windows path.
fn validate_relative_target(name: &str) -> Result<(), &'static str> {
@@ -322,7 +322,7 @@ mod tests {
/// the real filesystem. Returns (dir, file_path).
fn temp_file_setup(file_name: &str, contents: &str) -> (std::path::PathBuf, std::path::PathBuf) {
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();
let file = dir.join(file_name);
fs::write(&file, contents).unwrap();
+406
View File
@@ -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
//! 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
//! `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;
+14
View File
@@ -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;
+625
View File
@@ -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(&params).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(&params("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(&params("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(&params("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>,
}
/// 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)]
pub struct TvResult {
pub id: u64,
@@ -75,7 +75,7 @@ struct SearchMovieResponse {
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)]
pub struct MovieResult {
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 renamr_core::string_bindings;
use aurora_curator_core::format::ExpressionFormat;
use aurora_curator_core::string_bindings;
#[test]
fn simple_variable_substitution() {
+1
View File
@@ -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
Binary file not shown.
Binary file not shown.
-5
View File
@@ -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;
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Renamr</title>
<title>Aurora Curator</title>
</head>
<body>
<div id="root"></div>
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "renamr",
"name": "aurora-curator",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "renamr",
"name": "aurora-curator",
"version": "0.1.0",
"dependencies": {
"@tauri-apps/api": "^2.1.1",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "renamr",
"name": "aurora-curator",
"private": true,
"version": "0.1.0",
"type": "module",
+13 -35
View File
@@ -1,46 +1,24 @@
#!/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/.
"""
from PIL import Image, ImageDraw, ImageFont
from PIL import Image
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)
SIZE = 1024
BG_TOP = (91, 76, 245) # indigo
BG_BOTTOM = (56, 189, 178) # teal
FG = (255, 255, 255)
master = Image.open(SRC).convert("RGBA")
if master.size != (1024, 1024):
master = master.resize((1024, 1024), Image.LANCZOS)
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"))
sizes = [32, 128, 256, 512, 1024]
@@ -62,4 +40,4 @@ try:
except Exception as e:
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)))
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 KiB

+4 -3
View File
@@ -1,21 +1,22 @@
[package]
name = "renamr"
name = "aurora-curator"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "renamr_lib"
name = "aurora_curator_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
renamr-core = { path = "../crates/renamr-core" }
aurora-curator-core = { path = "../crates/aurora-curator-core" }
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
walkdir = "2"
tokio = { version = "1", features = ["sync"] }
dirs = "6"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default permissions for Renamr's main window.",
"description": "Default permissions for Aurora Curator's main window.",
"windows": ["main"],
"permissions": ["core:default", "dialog:default"]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 774 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 774 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 102 KiB

+551
View File
@@ -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();
}
}
+93
View File
@@ -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"));
}
}
+4 -4
View File
@@ -1,10 +1,10 @@
//! 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 -
//! list the log, and undo one batch from it.
use renamr_core::history::{self, HistoryBatch, HistoryEntry, RevertResult};
use renamr_core::rename::{RenameAction, RenameOutcome, RenameResult};
use aurora_curator_core::history::{self, HistoryBatch, HistoryEntry, RevertResult};
use aurora_curator_core::rename::{RenameAction, RenameOutcome, RenameResult};
use std::fs;
use std::path::PathBuf;
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`) -
/// 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
/// 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
/// 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
+45 -11
View File
@@ -1,13 +1,19 @@
mod cli;
mod episodes;
mod files;
mod history;
mod list;
mod metadata;
mod presets;
mod probe;
mod settings;
mod sfv;
mod subtitles;
use renamr_core::format::ExpressionFormat;
use renamr_core::matching::{self, EpisodeGuess, MovieGuess, ReleaseInfo};
use renamr_core::rename::{self, ConflictPolicy, RenameAction, RenamePlanItem, RenameResult};
use renamr_core::string_bindings;
use aurora_curator_core::format::ExpressionFormat;
use aurora_curator_core::matching::{self, EpisodeGuess, MovieGuess, ReleaseInfo};
use aurora_curator_core::rename::{self, ConflictPolicy, RenameAction, RenamePlanItem, RenameResult};
use aurora_curator_core::string_bindings;
use std::collections::HashMap;
use tauri::AppHandle;
@@ -42,18 +48,20 @@ fn guess_movie(filename: String) -> Option<MovieGuess> {
/// Guesses release-tag metadata (resolution, source, video/audio codec, release
/// 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,
/// so filename tags are the only source for these fields). 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.
/// calls, no file I/O. Complements `probe::probe_media_files` below, which reads the
/// same kind of resolution/codec data straight from a file's real container header
/// instead of guessing it from filename text - the Rename panel merges both,
/// preferring an explicit filename tag over a probed value when a file has both.
/// 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]
fn guess_release_info(filename: String) -> Option<ReleaseInfo> {
matching::guess_release_info(&filename)
}
/// 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
/// 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
@@ -79,6 +87,13 @@ async fn execute_rename(
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)]
pub fn run() {
tauri::Builder::default()
@@ -93,6 +108,25 @@ pub fn run() {
metadata::fetch_movie_metadata,
settings::get_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::undo_rename_batch,
presets::list_presets,
@@ -103,5 +137,5 @@ pub fn run() {
files::expand_dropped_paths,
])
.run(tauri::generate_context!())
.expect("error while running Renamr");
.expect("error while running Aurora Curator");
}
+61
View File
@@ -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");
}
}
+12 -1
View File
@@ -2,5 +2,16 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
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();
}
+238 -58
View File
@@ -1,15 +1,23 @@
//! 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 -
//! Fetch Data needs a series name and season/episode to search with, which is exactly
//! 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 std::collections::HashMap;
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.
#[derive(Debug, Clone, Deserialize)]
@@ -44,34 +52,207 @@ pub struct FetchResult {
pub outcome: FetchOutcome,
}
/// Resolves a batch of local guesses against TMDB. 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.
/// One episode of a resolved season - the shape both TMDB's and TVmaze's own
/// per-source episode types get mapped into by `AnyTvClient::fetch_season`, so the
/// caching/lookup loop in `run_fetch` never needs to know which source produced a
/// 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]
pub async fn fetch_metadata(app: AppHandle, items: Vec<FetchQuery>) -> Vec<FetchResult> {
let Some(token) = settings::tmdb_token(&app) else {
return items
.into_iter()
.map(|item| FetchResult {
id: item.id,
outcome: FetchOutcome::Failed {
error: "No TMDB API token configured — open Settings to add one.".to_string(),
},
})
.collect();
let client = match build_tv_client(&app) {
Ok(client) => client,
Err(error) => return failed_all(items, &error),
};
tauri::async_runtime::spawn(run_fetch(token, items))
tauri::async_runtime::spawn(run_fetch(client, items))
.await
.unwrap_or_default()
}
type SeasonLookup = Result<(String, Vec<SeasonEpisode>), String>;
async fn run_fetch(token: String, items: Vec<FetchQuery>) -> Vec<FetchResult> {
let client = TmdbClient::new(token);
async fn run_fetch(client: AnyTvClient, items: Vec<FetchQuery>) -> Vec<FetchResult> {
// 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.
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);
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);
}
@@ -103,19 +284,6 @@ async fn run_fetch(token: String, items: Vec<FetchQuery>) -> Vec<FetchResult> {
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
/// TMDB. Mirrors `FetchQuery`, but for the movie side of "Fetch Data".
#[derive(Debug, Clone, Deserialize)]
@@ -149,41 +317,53 @@ pub struct MovieFetchResult {
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
/// 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]
pub async fn fetch_movie_metadata(app: AppHandle, items: Vec<MovieFetchQuery>) -> Vec<MovieFetchResult> {
let Some(token) = settings::tmdb_token(&app) else {
return items
.into_iter()
.map(|item| MovieFetchResult {
id: item.id,
outcome: MovieFetchOutcome::Failed {
error: "No TMDB API token configured — open Settings to add one.".to_string(),
},
})
.collect();
let client = match settings::movie_metadata_source(&app) {
MovieMetadataSource::Tmdb => {
let Some(token) = settings::tmdb_token(&app) else {
return failed_all_movies(items, "No TMDB API token configured — open Settings to add one.");
};
AnyMovieClient::Tmdb(TmdbClient::new(token))
}
MovieMetadataSource::Omdb => {
let Some(key) = settings::omdb_api_key(&app) else {
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
.unwrap_or_default()
}
async fn run_movie_fetch(token: String, items: Vec<MovieFetchQuery>) -> Vec<MovieFetchResult> {
let client = TmdbClient::new(token);
/// Same idea as `failed_all` above, for the movie side's own result shape.
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());
for item in items {
let outcome = match client.lookup_movie(&item.title_query, item.year).await {
Ok(info) => MovieFetchOutcome::Found {
title: info.title,
release_date: info.release_date,
},
Err(renamr_core::web::tmdb::TmdbError::NotFound(_)) => MovieFetchOutcome::NotFound,
Err(error) => MovieFetchOutcome::Failed { error: error.to_string() },
Ok(MovieLookup::Found { title, release_date }) => MovieFetchOutcome::Found { title, release_date },
Ok(MovieLookup::NotFound) => MovieFetchOutcome::NotFound,
Err(error) => MovieFetchOutcome::Failed { error },
};
results.push(MovieFetchResult { id: item.id, outcome });
+41
View File
@@ -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()
}
+354 -7
View File
@@ -1,20 +1,88 @@
//! Small persisted app settings - currently just the TMDB API token ("Fetch Data" needs
//! it; everything else in the app works offline without any settings at all). Stored as
//! plain JSON in Tauri's per-OS app config directory (e.g.
//! `~/Library/Application Support/com.renamr.app/settings.json` on macOS,
//! `~/.config/renamr/settings.json` on Linux) - not the original FileBot's config
//! location, since that's Java Preferences-backed and OS/JVM-specific; this is Renamr's
//! own, analogous location for its own settings.
//! Small persisted app settings - the TMDB API token, the OMDb API key, and which TV
//! and movie metadata sources Fetch Data should use ("Fetch Data" is the only thing in
//! the app that needs settings at all; everything else works offline). Stored
//! as plain JSON in the OS's standard per-app config directory, joined with the app's
//! bundle identifier - `~/Library/Application Support/com.auroracurator.app/settings.json`
//! on macOS, `~/.config/com.auroracurator.app/settings.json` on Linux, `%APPDATA%\
//! 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 std::fs;
use std::path::PathBuf;
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)]
struct StoredSettings {
#[serde(default)]
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> {
@@ -47,6 +115,20 @@ pub fn tmdb_token(app: &AppHandle) -> Option<String> {
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]
pub fn get_tmdb_token(app: AppHandle) -> Option<String> {
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) };
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()));
}
}
+133
View File
@@ -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");
}
}
+138
View File
@@ -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(&params).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");
}
}
+3 -3
View File
@@ -1,8 +1,8 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Renamr",
"productName": "Aurora Curator",
"version": "0.1.0",
"identifier": "com.renamr.app",
"identifier": "com.auroracurator.app",
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
@@ -12,7 +12,7 @@
"app": {
"windows": [
{
"title": "Renamr",
"title": "Aurora Curator",
"width": 1100,
"height": 720,
"minWidth": 720,
+20 -1
View File
@@ -2,6 +2,11 @@ import { useState } from "react";
import Sidebar, { PANELS } from "./components/Sidebar";
import Header from "./components/Header";
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";
export default function App() {
@@ -14,7 +19,21 @@ export default function App() {
<div className="main-column">
<Header title={current.name} />
<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>
+134
View File
@@ -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);
}
+154
View File
@@ -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>
);
}
+136
View File
@@ -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);
}
+303
View File
@@ -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>
);
}
+139
View File
@@ -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);
}
+263
View File
@@ -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>
);
}
+8
View File
@@ -530,6 +530,14 @@
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 {
gap: 6px;
}
+359 -76
View File
@@ -31,7 +31,7 @@ interface FileEntry {
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 -
// this only recognizes patterns already present in the filename text itself.
interface EpisodeGuess {
@@ -40,7 +40,7 @@ interface EpisodeGuess {
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
// fallback whenever `guess_episode` finds no season/episode pattern - a release name
// 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.
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).
// 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
@@ -65,6 +65,32 @@ interface ReleaseInfo {
group: string | null;
}
// Result of real container-header probing (aurora-curator-core's `probe` module, exposed
// via the batched `probe_media_files` Tauri command) - resolution/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 {
return n.toString().padStart(2, "0");
}
@@ -78,7 +104,7 @@ interface RenamePlanEntry {
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.
type RenameOutcome =
| { status: "renamed"; newPath: string }
@@ -91,23 +117,36 @@ interface RenameResult {
outcome: RenameOutcome;
}
// Mirrors renamr-core's `RenameAction` - what actually happens at each computed target.
// "move" relocates the file in place (the only action Renamr had before); "copy" and
// Mirrors aurora-curator-core's `RenameAction` - what actually happens at each computed target.
// "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
// elsewhere, which is why only "move" updates the loaded item's own path/name (see
// confirmRename below).
type RenameAction = "move" | "copy" | "symlink";
// Mirrors renamr-core's `ConflictPolicy` - what happens when a computed target already
// exists. "skip" (the default, and the only behavior Renamr had before) leaves the
// Mirrors aurora-curator-core's `ConflictPolicy` - what happens when a computed target already
// 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;
// "override" replaces it outright (irreversible for whatever gets overwritten).
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
// real TMDB data. Needs a series name + season/episode to search with - exactly what
// Match already produces offline, which is why Fetch Data requires Match to have run
// first rather than doing its own from-scratch identification.
// real online TV metadata (TMDB or TVmaze, per the stored TvMetadataSource). Needs a
// series name + season/episode to search with - exactly what Match already produces
// offline, which is why Fetch Data requires Match to have run first rather than doing
// its own from-scratch identification.
interface FetchQuery {
id: string;
seriesQuery: string;
@@ -115,7 +154,7 @@ interface FetchQuery {
episode: number;
}
// Mirrors renamr-core's `FetchOutcome` (serde-tagged on "status").
// Mirrors aurora-curator-core's `FetchOutcome` (serde-tagged on "status").
type FetchOutcome =
| { status: "found"; seriesName: string; episodeTitle: string; airDate: string | null }
| { status: "notFound" }
@@ -135,7 +174,7 @@ interface MovieFetchQuery {
year: number | null;
}
// Mirrors renamr-core's `MovieFetchOutcome` (serde-tagged on "status").
// Mirrors aurora-curator-core's `MovieFetchOutcome` (serde-tagged on "status").
type MovieFetchOutcome =
| { status: "found"; title: string; releaseDate: string | null }
| { status: "notFound" }
@@ -146,7 +185,7 @@ interface MovieFetchResult {
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).
interface HistoryEntry {
source: string;
@@ -263,9 +302,9 @@ const SAMPLE_ITEMS: Item[] = [
];
// 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
// 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).
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
@@ -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 {
return {
id: entry.path,
@@ -395,18 +464,29 @@ export default function RenamePanel() {
const [renaming, setRenaming] = useState(false);
// What to actually do at each computed target - chosen in the confirmation modal,
// 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");
// 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).
const [conflictPolicy, setConflictPolicy] = useState<ConflictPolicy>("skip");
// 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
// the frontend after saving - see Settings modal below).
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 [tmdbTokenInput, setTmdbTokenInput] = useState("");
const [omdbKeyInput, setOmdbKeyInput] = useState("");
const [savingSettings, setSavingSettings] = useState(false);
const [fetching, setFetching] = useState(false);
// Rename history / undo. `null` batches = not loaded yet (also used to show a
@@ -438,6 +518,24 @@ export default function RenamePanel() {
.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
// latest expression/items instead of the stale values from its first render.
const expressionRef = useRef(expression);
@@ -545,13 +643,17 @@ export default function RenamePanel() {
// 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
// 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
// (`{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 targets = items.filter((i) => !i.isSample);
if (targets.length === 0) {
@@ -561,6 +663,12 @@ export default function RenamePanel() {
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(
targets.map(async (item): Promise<readonly [string, MatchGuess | null, ReleaseInfo | null]> => {
// 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 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:
// 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.
@@ -601,11 +710,13 @@ export default function RenamePanel() {
const matchedMovie = results.filter(([, guess]) => guess?.kind === "movie").length;
const matched = matchedTv + matchedMovie;
const tagged = results.filter(([, , release]) => release !== null).length;
const probed = [...probes.values()].filter((probe) => probe.container !== null).length;
setItems((prev) =>
prev.map((item) => {
const guess = guesses.get(item.id);
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
// contributes (or nothing at all, for a file with no season/episode or movie
@@ -620,8 +731,34 @@ export default function RenamePanel() {
}
: 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) {
return releaseBindings ? { ...item, bindings: { ...item.bindings, ...releaseBindings } } : item;
return releaseBindings || probeBindings
? { ...item, bindings: { ...item.bindings, ...probeBindings, ...releaseBindings } }
: item;
}
if (guess.kind === "tv") {
@@ -641,6 +778,7 @@ export default function RenamePanel() {
"show name": seriesName,
"season number": String(episode.season),
"episode number": pad2(episode.episode),
...probeBindings,
...releaseBindings,
},
};
@@ -659,6 +797,7 @@ export default function RenamePanel() {
// Human-readable aliases.
"movie name": title,
...(movie.year ? { year: String(movie.year) } : {}),
...probeBindings,
...releaseBindings,
},
};
@@ -675,7 +814,7 @@ export default function RenamePanel() {
setStatus(
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."
);
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
// "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
// 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 () => {
if (!renamePlan || renamePlan.length === 0) return;
setRenaming(true);
@@ -805,7 +944,7 @@ export default function RenamePanel() {
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
// batch of every successfully-renamed file after each Rename; this panel lists those
// batches and can revert one back to its original paths.
@@ -877,27 +1016,40 @@ export default function RenamePanel() {
const openSettings = () => {
setTmdbTokenInput("");
setOmdbKeyInput("");
setSettingsOpen(true);
};
const saveTmdbToken = async () => {
const trimmed = tmdbTokenInput.trim();
if (!trimmed) {
// Saves whichever credential field(s) the user actually typed into, then closes the
// modal - a single Save button covers both the TMDB token and the OMDb key so
// 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.
setSettingsOpen(false);
return;
}
setSavingSettings(true);
try {
await invoke("save_tmdb_token", { token: trimmed });
setHasTmdbToken(true);
if (trimmedTmdb) {
await invoke("save_tmdb_token", { token: trimmedTmdb });
setHasTmdbToken(true);
}
if (trimmedOmdb) {
await invoke("save_omdb_api_key", { key: trimmedOmdb });
setHasOmdbKey(true);
}
setTmdbTokenInput("");
setOmdbKeyInput("");
setSettingsOpen(false);
setStatus("TMDB token saved.");
window.clearTimeout((saveTmdbToken as any)._t);
(saveTmdbToken as any)._t = window.setTimeout(() => setStatus(null), 3000);
setStatus("Fetch Data settings saved.");
window.clearTimeout((saveCredentials as any)._t);
(saveCredentials as any)._t = window.setTimeout(() => setStatus(null), 3000);
} catch (err) {
setStatus(`Couldn't save the token: ${err}`);
setStatus(`Couldn't save: ${err}`);
} finally {
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/
// 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
// movie items are resolved via separate Tauri commands, run concurrently, and merged
// back into a single set of counts/status message.
// deliberately layered on top of local matching, not a replacement for it. TV items
// resolve against whichever source `tvSource` currently picks (TMDB, TVmaze, or
// 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 () => {
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(
(i) => !i.isSample && i.mediaKind === "tv" && i.bindings?.n && i.bindings?.season && i.bindings?.episode
);
@@ -947,6 +1137,25 @@ export default function RenamePanel() {
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);
try {
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"}`];
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`);
setStatus(parts.join(", ") + ".");
} catch (err) {
@@ -1239,7 +1448,7 @@ export default function RenamePanel() {
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
<span className="list-button-gap" />
<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)} />
</>
}
@@ -1306,15 +1515,32 @@ export default function RenamePanel() {
<code>{"{audio codec}"}</code>
<code>{"{release group}"}</code>
</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>
<p className="format-hint">
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
needs a network call, since it's all parsed straight from the filename itself (Renamr has no video/audio
file prober, so a tag that isn't in the filename text just won't be available). Fetch Data resolves the
season/episode or movie guess against real TMDB metadata for the real title, episode title, and
air/release date — needs a free TMDB API token set in <strong>TMDB Settings</strong> first. A batch can
freely mix TV and movie files — each file only fills in the fields that apply to it. FileBot's own short
needs a network call, since it's all parsed straight from the filename itself. Alongside that, Match also
probes each file's own container header (no ffmpeg/MediaInfo needed) for its real resolution, frame
rate, duration, and codecs — a filename tag wins whenever a file has both, but <code>{"{resolution}"}</code>,{" "}
<code>{"{video codec}"}</code>, and <code>{"{audio codec}"}</code> still fill in from the real file when
the filename doesn't carry that tag, and <code>{"{width}"}</code>/<code>{"{height}"}</code>/
<code>{"{fps}"}</code>/<code>{"{duration}"}</code>/<code>{"{file size}"}</code> are only ever available
this way. Fetch Data resolves the
season/episode or movie guess against real online metadata for the real title, episode title, and
air/release date. TV episodes can come from TMDB, TVmaze, or OMDb, and movies from TMDB or OMDb — pick
each independently in <strong>Fetch Data Settings</strong> (TVmaze is free and needs no credential at
all; TMDB and OMDb each need their own free API credential set there first). A batch can
freely mix TV and movie files, even against different sources — each file only fills in the fields that apply to it. FileBot's own short
field names (<code>{"{n}"}</code>, <code>{"{s00e00}"}</code>, <code>{"{t}"}</code>, <code>{"{y}"}</code>,
<code>{"{vf}"}</code>, <code>{"{vc}"}</code>, <code>{"{ac}"}</code>, <code>{"{group}"}</code>, etc.) still
work too, side by side with the names above.
@@ -1431,18 +1657,53 @@ export default function RenamePanel() {
{settingsOpen && (
<div className="modal-overlay" onClick={() => !savingSettings && setSettingsOpen(false)}>
<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">
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">
TheMovieDB
TMDB
</a>{" "}
to resolve local guesses into real episode titles and air dates. Get a free{" "}
<strong>API Read Access Token</strong> from your TMDB account under Settings → API, then paste it below.
The token is stored locally on this machine and never leaves it except to call TMDB directly.
account under Settings → API, and/or a free API key from{" "}
<a href="https://www.omdbapi.com/apikey.aspx" target="_blank" rel="noreferrer">
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>
<label htmlFor="tmdb-token" className="modal-field-label">
API Read Access Token
TMDB API Read Access Token
</label>
<input
id="tmdb-token"
@@ -1454,22 +1715,44 @@ export default function RenamePanel() {
onChange={(e) => setTmdbTokenInput(e.target.value)}
placeholder={hasTmdbToken ? "•••••••••• (already saved — enter a new token to replace it)" : "Paste your TMDB token here"}
/>
<div className="modal-actions modal-actions-split">
{hasTmdbToken ? (
<button className="action-button" onClick={clearTmdbToken} disabled={savingSettings}>
Remove Token
</button>
) : (
<span />
)}
<div className="modal-actions">
<button className="action-button" onClick={() => setSettingsOpen(false)} disabled={savingSettings}>
Cancel
</button>
<button className="action-button primary" onClick={saveTmdbToken} disabled={savingSettings}>
{savingSettings ? "Saving…" : "Save"}
</button>
</div>
{hasTmdbToken && (
<button
className="action-button modal-inline-remove"
onClick={clearTmdbToken}
disabled={savingSettings}
>
Remove TMDB Token
</button>
)}
<label htmlFor="omdb-key" className="modal-field-label">
OMDb API Key
</label>
<input
id="omdb-key"
className="modal-text-input"
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>
+105
View File
@@ -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);
}
+197
View File
@@ -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>
);
}
+1 -1
View File
@@ -47,7 +47,7 @@ export default function Sidebar({
</ul>
<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>
</div>
</nav>
+144
View File
@@ -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);
}
+455
View File
@@ -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>
);
}
+5
View File
@@ -6,6 +6,11 @@ import "./App.css";
import "./components/Sidebar.css";
import "./components/Header.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";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
+3 -2
View File
@@ -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
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 {
--dock: #08252b;
--dock-deep: #051115;