Add TMDB metadata client and Fetch Data: real episode titles/air dates via TMDB, with a Settings modal for the API token

This commit is contained in:
Renamr Scaffold
2026-08-29 03:01:19 +00:00
parent bab9bfa1ec
commit d1fc4039a7
12 changed files with 1231 additions and 27 deletions
Generated
+420 -2
View File
@@ -61,6 +61,16 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "assert-json-diff"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "atk"
version = "0.18.2"
@@ -323,6 +333,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -335,6 +362,15 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "colored"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "combine"
version = "4.6.8"
@@ -424,6 +460,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.1"
@@ -1066,8 +1111,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -1089,8 +1136,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
[[package]]
@@ -1241,6 +1291,25 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "h2"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.1",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1320,6 +1389,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.11.1"
@@ -1330,9 +1405,11 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
@@ -1340,6 +1417,22 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -1794,6 +1887,12 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "markup5ever"
version = "0.38.0"
@@ -1857,6 +1956,31 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "mockito"
version = "1.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0"
dependencies = [
"assert-json-diff",
"bytes",
"colored",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"log",
"pin-project-lite",
"rand 0.9.5",
"regex",
"serde_json",
"serde_urlencoded",
"similar",
"tokio",
]
[[package]]
name = "muda"
version = "0.19.3"
@@ -2353,6 +2477,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@@ -2430,6 +2563,62 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.20",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.20",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "quote"
version = "1.0.47"
@@ -2451,6 +2640,61 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -2544,11 +2788,52 @@ dependencies = [
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
@@ -2638,6 +2923,20 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -2653,12 +2952,53 @@ dependencies = [
"semver",
]
[[package]]
name = "rustls"
version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@@ -2849,6 +3189,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.22.0"
@@ -2920,7 +3272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -2936,6 +3288,12 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "siphasher"
version = "1.0.3"
@@ -3071,6 +3429,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.8"
@@ -3234,7 +3598,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.4",
"serde",
"serde_json",
"serde_repr",
@@ -3603,11 +3967,34 @@ dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
@@ -3617,6 +4004,7 @@ dependencies = [
"bytes",
"futures-core",
"futures-sink",
"libc",
"pin-project-lite",
"tokio",
]
@@ -3898,6 +4286,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -4151,6 +4545,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -4381,6 +4784,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -4787,6 +5199,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.5"
+48 -21
View File
@@ -6,11 +6,12 @@ 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
detection ("Match") is working, and the actual on-disk rename operation is wired up
(same-directory rename, skip-on-conflict, with a confirmation step showing the full
old-name/new-name plan before anything touches disk — there is no undo yet). Full
media-metadata bindings, the fuzzy similarity/matching engine, and online metadata
lookups are not yet ported. See "Porting roadmap" below.
detection ("Match") is working, real online metadata lookups against TMDB ("Fetch
Data") are working, and the actual on-disk rename operation is wired up (same-directory
rename, skip-on-conflict, with a confirmation step showing the full old-name/new-name
plan before anything touches disk — there is no undo yet). Full media-metadata
bindings and the fuzzy similarity/matching engine are not yet ported. See "Porting
roadmap" below.
## Why a rewrite, and why this stack
@@ -50,19 +51,24 @@ translate directly.
```
crates/renamr-core/ Pure Rust core: naming-expression engine (ported), local
filename-based season/episode matching (ported, `matching/`), and
the on-disk rename engine (ported for the same-directory case,
filename-based season/episode matching (ported, `matching/`), a
TMDB metadata client (`web/tmdb.rs` — TV search + episode lookup,
tested against a mocked HTTP server via `mockito`), and the
on-disk rename engine (ported for the same-directory case,
`rename/`) — skip-on-conflict, no move/organize-into-folders yet.
Not yet ported: the fuzzy similarity engine, full media metadata
bindings, web datasource clients (TMDB/TVDB/AniDB/etc). No Tauri
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
(naming-scheme rendering, filename matching, executing a rename
plan, native file/folder dialogs, drag-and-drop expansion), app
metadata/icons/window config (tauri.conf.json).
(naming-scheme rendering, filename matching, TMDB metadata fetch,
executing a rename plan, native file/folder dialogs, drag-and-drop
expansion), plus `settings.rs` (TMDB token, stored as JSON in the
OS app-config directory), 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 matching, and a
drag-and-drop, live naming-scheme preview, local matching, TMDB
fetch (behind a Settings modal for the API token), and a
confirm-before-touching-disk modal for the actual rename.
scripts/gen_icons.py Regenerates src-tauri/icons/ (placeholder branding — swap the
colors/glyph in the script, or replace the icons directly, to
@@ -89,11 +95,24 @@ npm run tauri build
```
Verified in the environment this was built in: `cargo test -p renamr-core` (all
passing), `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.
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.
### Setting up TMDB (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:
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`.
## Porting roadmap (not yet done)
@@ -111,13 +130,21 @@ ported from, for anyone picking this up:
3. **Matching/similarity engine** — `net.filebot.similarity.*`. Local filename-based
season/episode detection is done (`crates/renamr-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). Still not ported:
`EpisodeMetrics.java` (849 lines, the fuzzy title-matching score cascade used once
there's an actual metadata source to score candidates against) and `DateMatcher`.
which lacks lookahead/lookbehind — wired to the "Match" button). Now that TMDB is
wired up (see item 5 below), `EpisodeMetrics.java` (849 lines, the fuzzy title-matching
score cascade) is the next thing this actually blocks on: today "Fetch Data" just
takes TMDB's first (TMDB-ranked) search result, which is right most of the time but
has no way to catch a wrong pick among several similarly-named shows. `DateMatcher`
also still not ported.
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. Use `reqwest` + `serde_json`/`serde`.
AniDB, OMDb, TVmaze, OpenSubtitles, etc. TMDB is done for TV (`crates/renamr-core/src/web/tmdb.rs`,
`reqwest` + `serde_json`, Bearer-token auth) — search-by-name plus per-season episode
lookup, wired to "Fetch Data". Picks the first (TMDB-ranked) search result rather
than disambiguating between multiple plausible shows — a real fix needs the
`EpisodeMetrics` fuzzy-matching port above. TMDB movie lookup and all other sources
(TheTVDB, AniDB, OMDb, TVmaze, OpenSubtitles) are not yet started.
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 core same-directory
+8
View File
@@ -20,4 +20,12 @@ serde_json = "1"
# are adapted to rely on \b word boundaries instead).
regex = "1"
# TMDB metadata client ("Fetch Data"). rustls instead of the default openssl-backed TLS
# so this doesn't need system OpenSSL dev headers on every platform we target.
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt", "macros"] }
[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.
mockito = "1"
+1
View File
@@ -8,6 +8,7 @@
pub mod format;
pub mod matching;
pub mod rename;
pub mod web;
use rhai::Dynamic;
use std::collections::HashMap;
+5
View File
@@ -0,0 +1,5 @@
//! 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;
+307
View File
@@ -0,0 +1,307 @@
//! A small client for [TMDB](https://www.themoviedb.org/)'s v3 API: search for a TV
//! show by name, then look up a specific episode's real title/air date.
//!
//! Ports the *lookup* half of what `net.filebot.web.TheMovieDBClient` did in the
//! original; this is intentionally scoped to TV episodes only for v1 (movies are a
//! straightforward follow-up using the parallel `/search/movie` + `/movie/{id}`
//! endpoints, not yet added). Authenticates with a TMDB **API Read Access Token**
//! (the Bearer-token method TMDB documents as the current default) rather than the
//! older `api_key` query parameter.
use serde::Deserialize;
use thiserror::Error;
const DEFAULT_BASE_URL: &str = "https://api.themoviedb.org/3";
#[derive(Debug, Error)]
pub enum TmdbError {
#[error("network error talking to TMDB: {0}")]
Network(String),
#[error("TMDB API error (status {status}): {message}")]
Api { status: u16, message: String },
#[error("no TMDB match found for \"{0}\"")]
NotFound(String),
}
#[derive(Debug, Clone, Deserialize)]
struct SearchTvResponse {
#[serde(default)]
results: Vec<TvResult>,
}
/// One TMDB search hit. Only the fields Renamr actually uses are kept.
#[derive(Debug, Clone, Deserialize)]
pub struct TvResult {
pub id: u64,
pub name: String,
}
#[derive(Debug, Clone, Deserialize)]
struct SeasonResponse {
#[serde(default)]
episodes: Vec<EpisodeResult>,
}
#[derive(Debug, Clone, Deserialize)]
struct EpisodeResult {
episode_number: i64,
name: String,
#[serde(default)]
air_date: Option<String>,
}
/// Real metadata for one episode, resolved from TMDB - contrast with
/// `matching::EpisodeGuess`, which only has whatever a filename pattern implied.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpisodeInfo {
/// TMDB's canonical show name (may differ in punctuation/capitalization from
/// whatever the local filename guess produced).
pub series_name: String,
pub episode_title: String,
pub air_date: Option<String>,
}
pub struct TmdbClient {
http: reqwest::Client,
token: String,
base_url: String,
}
impl TmdbClient {
pub fn new(token: impl Into<String>) -> Self {
Self {
http: reqwest::Client::new(),
token: token.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 TMDB 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, TmdbError> {
let url = format!("{}{path}", self.base_url);
let response = self
.http
.get(&url)
.bearer_auth(&self.token)
.query(query)
.send()
.await
.map_err(|e| TmdbError::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(TmdbError::Api { status, message });
}
response
.json::<T>()
.await
.map_err(|e| TmdbError::Network(e.to_string()))
}
/// Searches for a TV show by name. Results come back TMDB-ranked (relevance /
/// popularity); callers wanting "the best guess" should just take the first one.
pub async fn search_tv(&self, query: &str) -> Result<Vec<TvResult>, TmdbError> {
let parsed: SearchTvResponse = self.get_json("/search/tv", &[("query", query)]).await?;
Ok(parsed.results)
}
/// Fetches every episode of one season in a single request. Prefer this over
/// calling `episode()` per-episode when looking up several episodes from the same
/// season (a whole loaded folder is usually one season) - the season endpoint
/// already returns the full episode list, so there's no need to re-fetch it per
/// episode; cache this call's result and look episodes up from it locally instead.
pub async fn season_episodes(&self, series_id: u64, season: i32) -> Result<Vec<SeasonEpisode>, TmdbError> {
let path = format!("/tv/{series_id}/season/{season}");
let parsed: SeasonResponse = self.get_json(&path, &[]).await?;
Ok(parsed
.episodes
.into_iter()
.map(|e| SeasonEpisode {
episode_number: e.episode_number as i32,
title: e.name,
air_date: e.air_date,
})
.collect())
}
/// Looks up one episode's real title/air date from a known TMDB series id. Fetches
/// the whole season each call - if you need several episodes from the same season,
/// call `season_episodes()` once instead and look them up locally.
pub async fn episode(&self, series_id: u64, season: i32, episode: i32) -> Result<EpisodeResultInfo, TmdbError> {
let episodes = self.season_episodes(series_id, season).await?;
episodes
.into_iter()
.find(|e| e.episode_number == episode)
.map(|e| EpisodeResultInfo {
episode_title: e.title,
air_date: e.air_date,
})
.ok_or_else(|| TmdbError::NotFound(format!("S{season:02}E{episode:02}")))
}
/// Convenience: search for the best-matching show by name, then fetch a specific
/// episode. Picks the first search result - good enough for v1 since TMDB already
/// ranks by relevance; a proper fuzzy-matching disambiguation pass (porting
/// `EpisodeMetrics`) is still on the roadmap for when a search returns multiple
/// plausible shows.
pub async fn lookup_episode(
&self,
series_query: &str,
season: i32,
episode: i32,
) -> Result<EpisodeInfo, TmdbError> {
let results = self.search_tv(series_query).await?;
let best = results
.into_iter()
.next()
.ok_or_else(|| TmdbError::NotFound(series_query.to_string()))?;
let info = self.episode(best.id, season, episode).await?;
Ok(EpisodeInfo {
series_name: best.name,
episode_title: info.episode_title,
air_date: info.air_date,
})
}
}
/// Just the episode-specific fields, before the series name (known by the caller of
/// `episode()`, which already has a series id) is attached.
pub struct EpisodeResultInfo {
pub episode_title: String,
pub air_date: Option<String>,
}
/// One episode of a season, as returned by `season_episodes()`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SeasonEpisode {
pub episode_number: i32,
pub title: String,
pub air_date: Option<String>,
}
#[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/tv")
.match_query(mockito::Matcher::UrlEncoded("query".into(), "Breaking Bad".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"results":[{"id":1396,"name":"Breaking Bad"}]}"#)
.create_async()
.await;
let _season_mock = server
.mock("GET", "/tv/1396/season/5")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"episodes":[
{"episode_number":13,"name":"To'hajiilee","air_date":"2013-09-08"},
{"episode_number":14,"name":"Ozymandias","air_date":"2013-09-15"}
]}"#,
)
.create_async()
.await;
let client = TmdbClient::new("test-token").with_base_url(server.url());
let info = client.lookup_episode("Breaking Bad", 5, 14).await.unwrap();
assert_eq!(
info,
EpisodeInfo {
series_name: "Breaking Bad".to_string(),
episode_title: "Ozymandias".to_string(),
air_date: Some("2013-09-15".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/tv")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"results":[]}"#)
.create_async()
.await;
let client = TmdbClient::new("test-token").with_base_url(server.url());
let result = client.lookup_episode("Some Nonexistent Show", 1, 1).await;
assert!(matches!(result, Err(TmdbError::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/tv")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"results":[{"id":42,"name":"Some Show"}]}"#)
.create_async()
.await;
let _season_mock = server
.mock("GET", "/tv/42/season/1")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"episodes":[{"episode_number":1,"name":"Pilot","air_date":"2020-01-01"}]}"#)
.create_async()
.await;
let client = TmdbClient::new("test-token").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(TmdbError::NotFound(_))));
}
#[tokio::test]
async fn surfaces_an_invalid_token_as_an_api_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/search/tv")
.match_query(mockito::Matcher::Any)
.with_status(401)
.with_header("content-type", "application/json")
.with_body(r#"{"status_message":"Invalid API key: You must be granted a valid key.","status_code":7}"#)
.create_async()
.await;
let client = TmdbClient::new("bad-token").with_base_url(server.url());
let result = client.search_tv("anything").await;
match result {
Err(TmdbError::Api { status, .. }) => assert_eq!(status, 401),
other => panic!("expected Api error, got {other:?}"),
}
}
}
+5
View File
@@ -1,4 +1,6 @@
mod files;
mod metadata;
mod settings;
use renamr_core::format::ExpressionFormat;
use renamr_core::matching::{self, EpisodeGuess};
@@ -46,6 +48,9 @@ pub fn run() {
render_expression,
guess_episode,
execute_rename,
metadata::fetch_metadata,
settings::get_tmdb_token,
settings::save_tmdb_token,
files::pick_files,
files::pick_folder,
files::expand_dropped_paths,
+117
View File
@@ -0,0 +1,117 @@
//! 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
//! 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.
use renamr_core::web::tmdb::{SeasonEpisode, TmdbClient};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tauri::AppHandle;
use crate::settings;
/// One file's local guess, sent up from the UI to be resolved against TMDB.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FetchQuery {
/// Echoed back in the result so the frontend can map it back to the right item.
pub id: String,
pub series_query: String,
pub season: i32,
pub episode: i32,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum FetchOutcome {
Found {
series_name: String,
episode_title: String,
air_date: Option<String>,
},
/// Either nothing on TMDB matched the series name, or the show was found but that
/// season doesn't have this episode number.
NotFound,
Failed {
error: String,
},
}
#[derive(Debug, Clone, Serialize)]
pub struct FetchResult {
pub id: String,
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.
#[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();
};
tauri::async_runtime::spawn(run_fetch(token, 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);
// 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();
let mut results = Vec::with_capacity(items.len());
for item in items {
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;
season_cache.insert(key.clone(), looked_up);
}
let outcome = match season_cache.get(&key).expect("just inserted") {
Ok((series_name, episodes)) => match episodes.iter().find(|e| e.episode_number == item.episode) {
Some(found) => FetchOutcome::Found {
series_name: series_name.clone(),
episode_title: found.title.clone(),
air_date: found.air_date.clone(),
},
None => FetchOutcome::NotFound,
},
Err(error) => FetchOutcome::Failed { error: error.clone() },
};
results.push(FetchResult { id: item.id, outcome });
}
results
}
async fn fetch_season(client: &TmdbClient, series_query: &str, season: i32) -> SeasonLookup {
let search_results = client.search_tv(series_query).await.map_err(|e| e.to_string())?;
let best = search_results
.into_iter()
.next()
.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))
}
+61
View File
@@ -0,0 +1,61 @@
//! 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.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
#[derive(Debug, Default, Serialize, Deserialize)]
struct StoredSettings {
#[serde(default)]
tmdb_token: Option<String>,
}
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_config_dir()
.map_err(|e| format!("couldn't resolve the app config directory: {e}"))?;
fs::create_dir_all(&dir).map_err(|e| format!("couldn't create {}: {e}", dir.display()))?;
Ok(dir.join("settings.json"))
}
fn load(app: &AppHandle) -> StoredSettings {
let Ok(path) = settings_path(app) else {
return StoredSettings::default();
};
fs::read_to_string(path)
.ok()
.and_then(|contents| serde_json::from_str(&contents).ok())
.unwrap_or_default()
}
fn save(app: &AppHandle, settings: &StoredSettings) -> Result<(), String> {
let path = settings_path(app)?;
let json = serde_json::to_string_pretty(settings).map_err(|e| e.to_string())?;
fs::write(&path, json).map_err(|e| format!("couldn't write {}: {e}", path.display()))
}
/// Returns the stored TMDB token, or `None` if nothing (or only blank text) is saved.
pub fn tmdb_token(app: &AppHandle) -> Option<String> {
load(app).tmdb_token.filter(|t| !t.trim().is_empty())
}
#[tauri::command]
pub fn get_tmdb_token(app: AppHandle) -> Option<String> {
tmdb_token(&app)
}
#[tauri::command]
pub fn save_tmdb_token(app: AppHandle, token: String) -> Result<(), String> {
let mut settings = load(&app);
let trimmed = token.trim().to_string();
settings.tmdb_token = if trimmed.is_empty() { None } else { Some(trimmed) };
save(&app, &settings)
}
+9
View File
@@ -144,3 +144,12 @@ export function FetchIcon() {
</svg>
);
}
export function KeyIcon() {
return (
<svg {...{ ...base, width: 15, height: 15 }}>
<circle cx="8" cy="15" r="4" />
<path d="M11 12l9-9M16 5l3 3M13 8l3 3" />
</svg>
);
}
+39
View File
@@ -127,6 +127,12 @@
background: #28515a;
}
.list-icon-button:disabled {
opacity: 0.45;
cursor: default;
pointer-events: none;
}
.load-menu {
position: relative;
display: inline-flex;
@@ -362,6 +368,39 @@
padding: 0 18px;
}
.modal-actions-split {
justify-content: space-between;
align-items: center;
}
.modal-card-compact {
max-height: none;
}
.modal-subtitle a {
color: var(--aurora-cyan);
}
.modal-field-label {
display: block;
font-size: 11px;
font-weight: 700;
color: var(--muted-text);
margin: 4px 0 6px;
}
.modal-text-input {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--surface-deep);
background: var(--surface);
color: var(--text);
font-family: ui-monospace, "SF Mono", monospace;
font-size: 12.5px;
}
.status-bar {
position: absolute;
left: 18px;
+211 -4
View File
@@ -6,6 +6,7 @@ import {
FetchIcon,
GearIcon,
HistoryIcon,
KeyIcon,
LoadIcon,
PresetsIcon,
RemoveIcon,
@@ -61,6 +62,28 @@ interface RenameResult {
outcome: RenameOutcome;
}
// 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.
interface FetchQuery {
id: string;
seriesQuery: string;
season: number;
episode: number;
}
// Mirrors renamr-core's `FetchOutcome` (serde-tagged on "status").
type FetchOutcome =
| { status: "found"; seriesName: string; episodeTitle: string; airDate: string | null }
| { status: "notFound" }
| { status: "failed"; error: string };
interface FetchResult {
id: string;
outcome: FetchOutcome;
}
function fileNameFromPath(path: string): string {
const parts = path.split(/[\\/]/);
return parts[parts.length - 1] ?? path;
@@ -122,13 +145,15 @@ function ListButton({
icon,
label,
onClick,
disabled,
}: {
icon: JSX.Element;
label: string;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button className="list-icon-button" title={label} onClick={onClick}>
<button className="list-icon-button" title={label} onClick={onClick} disabled={disabled}>
{icon}
</button>
);
@@ -186,6 +211,20 @@ export default function RenamePanel() {
const [renamePlan, setRenamePlan] = useState<RenamePlanEntry[] | null>(null);
const [renamePlanSkipped, setRenamePlanSkipped] = useState(0);
const [renaming, setRenaming] = useState(false);
// 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);
const [settingsOpen, setSettingsOpen] = useState(false);
const [tmdbTokenInput, setTmdbTokenInput] = useState("");
const [savingSettings, setSavingSettings] = useState(false);
const [fetching, setFetching] = useState(false);
useEffect(() => {
invoke<string | null>("get_tmdb_token")
.then((token) => setHasTmdbToken(!!token))
.catch(() => setHasTmdbToken(false));
}, []);
// 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.
@@ -466,6 +505,125 @@ export default function RenamePanel() {
setRenamePlan(null);
};
const openSettings = () => {
setTmdbTokenInput("");
setSettingsOpen(true);
};
const saveTmdbToken = async () => {
const trimmed = tmdbTokenInput.trim();
if (!trimmed) {
// 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);
setTmdbTokenInput("");
setSettingsOpen(false);
setStatus("TMDB token saved.");
window.clearTimeout((saveTmdbToken as any)._t);
(saveTmdbToken as any)._t = window.setTimeout(() => setStatus(null), 3000);
} catch (err) {
setStatus(`Couldn't save the token: ${err}`);
} finally {
setSavingSettings(false);
}
};
const clearTmdbToken = async () => {
setSavingSettings(true);
try {
await invoke("save_tmdb_token", { token: "" });
setHasTmdbToken(false);
setTmdbTokenInput("");
setStatus("TMDB token removed.");
window.clearTimeout((clearTmdbToken as any)._t);
(clearTmdbToken as any)._t = window.setTimeout(() => setStatus(null), 3000);
} catch (err) {
setStatus(`Couldn't remove the token: ${err}`);
} finally {
setSavingSettings(false);
}
};
// "Fetch Data": resolve each already-Matched file's local guess against real TMDB
// metadata. Requires Match to have already populated a series name + season/episode
// (that's the search query) - this is deliberately layered on top of local matching,
// not a replacement for it.
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 targets = items.filter((i) => !i.isSample && i.bindings?.n && i.bindings?.season && i.bindings?.episode);
if (targets.length === 0) {
setStatus("Run Match first — Fetch Data needs a guessed series name and season/episode to look up.");
window.clearTimeout((runFetch as any)._t);
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
return;
}
setFetching(true);
try {
const query: FetchQuery[] = targets.map((item) => ({
id: item.id,
seriesQuery: item.bindings!.n,
season: Number(item.bindings!.season),
episode: Number(item.bindings!.episode),
}));
const results = await invoke<FetchResult[]>("fetch_metadata", { items: query });
const byId = new Map(results.map((r) => [r.id, r] as const));
// Compute counts from `results` directly, not inside the setItems updater below -
// same rule as runMatching/confirmRename above (React doesn't run a functional
// updater synchronously, so a counter mutated in there wouldn't be ready yet).
const found = results.filter((r) => r.outcome.status === "found").length;
const notFound = results.filter((r) => r.outcome.status === "notFound").length;
const failed = results.filter((r) => r.outcome.status === "failed").length;
setItems((prev) =>
prev.map((item) => {
const result = byId.get(item.id);
if (!result || result.outcome.status !== "found") return item;
const { seriesName, episodeTitle, airDate } = result.outcome;
return {
...item,
bindings: {
...item.bindings,
n: seriesName,
t: episodeTitle,
...(airDate ? { airdate: airDate } : {}),
},
};
})
);
// Showcase the episode title field, but only the first time and only if the user
// hasn't already customized the naming scheme away from the Match-set default.
if (expressionRef.current === "{n} - {s00e00}" && found > 0) {
setExpression("{n} - {s00e00} - {t}");
}
const parts = [`Fetched real metadata for ${found} of ${targets.length} file${targets.length === 1 ? "" : "s"}`];
if (notFound > 0) parts.push(`${notFound} not found on TMDB`);
if (failed > 0) parts.push(`${failed} failed`);
setStatus(parts.join(", ") + ".");
} catch (err) {
setStatus(`Fetch failed: ${err}`);
} finally {
setFetching(false);
window.clearTimeout((runFetch as any)._t);
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 5000);
}
};
const removeSelected = () => {
if (!selected) return notImplemented("Excluding items");
setItems((prev) => {
@@ -552,7 +710,8 @@ export default function RenamePanel() {
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => notImplemented("Reordering")} />
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
<span className="list-button-gap" />
<ListButton icon={<FetchIcon />} label="Fetch Data" onClick={() => notImplemented("Online matching")} />
<ListButton icon={<FetchIcon />} label="Fetch Data" onClick={runFetch} disabled={fetching} />
<ListButton icon={<KeyIcon />} label="TMDB Settings" onClick={openSettings} />
<ListButton icon={<GearIcon />} label="Naming Scheme" onClick={() => setEditingFormat((v) => !v)} />
</>
}
@@ -574,8 +733,9 @@ export default function RenamePanel() {
Rust naming-expression engine (Rhai-backed) through Tauri. Loaded files start with only{" "}
<code>{"{name}"}</code>/<code>{"{ext}"}</code> (their own filename) — click <strong>Match</strong> to scan
filenames locally for season/episode patterns and populate <code>{"{n}"}</code>/<code>{"{s00e00}"}</code>/
<code>{"{season}"}</code>/<code>{"{episode}"}</code>. That's filename parsing only, no network calls;{" "}
<strong>Fetch Data</strong> (online metadata matching) isn't built yet.
<code>{"{season}"}</code>/<code>{"{episode}"}</code> (no network calls), then <strong>Fetch Data</strong> to
resolve those guesses against real TMDB metadata and populate <code>{"{t}"}</code> (episode title) and{" "}
<code>{"{airdate}"}</code> too — needs a free TMDB API token set in <strong>TMDB Settings</strong> first.
</p>
</div>
)}
@@ -619,6 +779,53 @@ export default function RenamePanel() {
</div>
</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">TMDB Settings</h2>
<p className="modal-subtitle">
Fetch Data uses{" "}
<a href="https://www.themoviedb.org/" target="_blank" rel="noreferrer">
TheMovieDB
</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.
</p>
<label htmlFor="tmdb-token" className="modal-field-label">
API Read Access Token
</label>
<input
id="tmdb-token"
className="modal-text-input"
type="password"
autoComplete="off"
spellCheck={false}
value={tmdbTokenInput}
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>
</div>
</div>
</div>
)}
</div>
);
}