From d1fc4039a7e6e856d0c93b5ea2bc5c9c663b2121 Mon Sep 17 00:00:00 2001
From: Renamr Scaffold
Date: Sat, 29 Aug 2026 03:01:19 +0000
Subject: [PATCH] Add TMDB metadata client and Fetch Data: real episode
titles/air dates via TMDB, with a Settings modal for the API token
---
Cargo.lock | 422 ++++++++++++++++++++++++++++-
README.md | 69 +++--
crates/renamr-core/Cargo.toml | 8 +
crates/renamr-core/src/lib.rs | 1 +
crates/renamr-core/src/web/mod.rs | 5 +
crates/renamr-core/src/web/tmdb.rs | 307 +++++++++++++++++++++
src-tauri/src/lib.rs | 5 +
src-tauri/src/metadata.rs | 117 ++++++++
src-tauri/src/settings.rs | 61 +++++
src/components/Icons.tsx | 9 +
src/components/RenamePanel.css | 39 +++
src/components/RenamePanel.tsx | 215 ++++++++++++++-
12 files changed, 1231 insertions(+), 27 deletions(-)
create mode 100644 crates/renamr-core/src/web/mod.rs
create mode 100644 crates/renamr-core/src/web/tmdb.rs
create mode 100644 src-tauri/src/metadata.rs
create mode 100644 src-tauri/src/settings.rs
diff --git a/Cargo.lock b/Cargo.lock
index 6c9c45d..e176cc2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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"
diff --git a/README.md b/README.md
index 17ef39a..a886929 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/crates/renamr-core/Cargo.toml b/crates/renamr-core/Cargo.toml
index d52098e..506981c 100644
--- a/crates/renamr-core/Cargo.toml
+++ b/crates/renamr-core/Cargo.toml
@@ -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"
diff --git a/crates/renamr-core/src/lib.rs b/crates/renamr-core/src/lib.rs
index b15b594..8e8d8c5 100644
--- a/crates/renamr-core/src/lib.rs
+++ b/crates/renamr-core/src/lib.rs
@@ -8,6 +8,7 @@
pub mod format;
pub mod matching;
pub mod rename;
+pub mod web;
use rhai::Dynamic;
use std::collections::HashMap;
diff --git a/crates/renamr-core/src/web/mod.rs b/crates/renamr-core/src/web/mod.rs
new file mode 100644
index 0000000..b3b8dd5
--- /dev/null
+++ b/crates/renamr-core/src/web/mod.rs
@@ -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;
diff --git a/crates/renamr-core/src/web/tmdb.rs b/crates/renamr-core/src/web/tmdb.rs
new file mode 100644
index 0000000..32980f7
--- /dev/null
+++ b/crates/renamr-core/src/web/tmdb.rs
@@ -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,
+}
+
+/// 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,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct EpisodeResult {
+ episode_number: i64,
+ name: String,
+ #[serde(default)]
+ air_date: Option,
+}
+
+/// 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,
+}
+
+pub struct TmdbClient {
+ http: reqwest::Client,
+ token: String,
+ base_url: String,
+}
+
+impl TmdbClient {
+ pub fn new(token: impl Into) -> 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) -> Self {
+ self.base_url = base_url.into();
+ self
+ }
+
+ async fn get_json Deserialize<'de>>(
+ &self,
+ path: &str,
+ query: &[(&str, &str)],
+ ) -> Result {
+ 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::()
+ .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, 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, 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 {
+ 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 {
+ 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,
+}
+
+/// 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,
+}
+
+#[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:?}"),
+ }
+ }
+}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index e3fea05..753d306 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -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,
diff --git a/src-tauri/src/metadata.rs b/src-tauri/src/metadata.rs
new file mode 100644
index 0000000..e2f39f5
--- /dev/null
+++ b/src-tauri/src/metadata.rs
@@ -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,
+ },
+ /// 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) -> Vec {
+ 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), String>;
+
+async fn run_fetch(token: String, items: Vec) -> Vec {
+ 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))
+}
diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs
new file mode 100644
index 0000000..46ccf53
--- /dev/null
+++ b/src-tauri/src/settings.rs
@@ -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,
+}
+
+fn settings_path(app: &AppHandle) -> Result {
+ 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 {
+ load(app).tmdb_token.filter(|t| !t.trim().is_empty())
+}
+
+#[tauri::command]
+pub fn get_tmdb_token(app: AppHandle) -> Option {
+ 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)
+}
diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx
index f61b190..a71b490 100644
--- a/src/components/Icons.tsx
+++ b/src/components/Icons.tsx
@@ -144,3 +144,12 @@ export function FetchIcon() {
);
}
+
+export function KeyIcon() {
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/RenamePanel.css b/src/components/RenamePanel.css
index 0f64ef4..65f334b 100644
--- a/src/components/RenamePanel.css
+++ b/src/components/RenamePanel.css
@@ -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;
diff --git a/src/components/RenamePanel.tsx b/src/components/RenamePanel.tsx
index f7597f3..6845d80 100644
--- a/src/components/RenamePanel.tsx
+++ b/src/components/RenamePanel.tsx
@@ -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 (
-
+
{icon}
);
@@ -186,6 +211,20 @@ export default function RenamePanel() {
const [renamePlan, setRenamePlan] = useState(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(null);
+ const [settingsOpen, setSettingsOpen] = useState(false);
+ const [tmdbTokenInput, setTmdbTokenInput] = useState("");
+ const [savingSettings, setSavingSettings] = useState(false);
+ const [fetching, setFetching] = useState(false);
+
+ useEffect(() => {
+ invoke("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("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() {
} label="Align Up" onClick={() => notImplemented("Reordering")} />
- } label="Fetch Data" onClick={() => notImplemented("Online matching")} />
+ } label="Fetch Data" onClick={runFetch} disabled={fetching} />
+ } label="TMDB Settings" onClick={openSettings} />
} 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{" "}
{"{name}"}/{"{ext}"} (their own filename) — click Match to scan
filenames locally for season/episode patterns and populate {"{n}"}/{"{s00e00}"}/
- {"{season}"}/{"{episode}"}. That's filename parsing only, no network calls;{" "}
- Fetch Data (online metadata matching) isn't built yet.
+ {"{season}"}/{"{episode}"} (no network calls), then Fetch Data to
+ resolve those guesses against real TMDB metadata and populate {"{t}"} (episode title) and{" "}
+ {"{airdate}"} too — needs a free TMDB API token set in TMDB Settings first.
)}
@@ -619,6 +779,53 @@ export default function RenamePanel() {
)}
+
+ {settingsOpen && (
+ !savingSettings && setSettingsOpen(false)}>
+
e.stopPropagation()}>
+
TMDB Settings
+
+ Fetch Data uses{" "}
+
+ TheMovieDB
+ {" "}
+ to resolve local guesses into real episode titles and air dates. Get a free{" "}
+ API Read Access Token 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.
+
+
+ API Read Access Token
+
+
setTmdbTokenInput(e.target.value)}
+ placeholder={hasTmdbToken ? "•••••••••• (already saved — enter a new token to replace it)" : "Paste your TMDB token here"}
+ />
+
+ {hasTmdbToken ? (
+
+ Remove Token
+
+ ) : (
+
+ )}
+
+ setSettingsOpen(false)} disabled={savingSettings}>
+ Cancel
+
+
+ {savingSettings ? "Saving…" : "Save"}
+
+
+
+
+
+ )}
);
}