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 ( - ); @@ -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. +

+ + setTmdbTokenInput(e.target.value)} + placeholder={hasTmdbToken ? "•••••••••• (already saved — enter a new token to replace it)" : "Paste your TMDB token here"} + /> +
+ {hasTmdbToken ? ( + + ) : ( + + )} +
+ + +
+
+
+
+ )} ); }