Add TMDB metadata client and Fetch Data: real episode titles/air dates via TMDB, with a Settings modal for the API token
This commit is contained in:
Generated
+420
-2
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
pub mod format;
|
||||
pub mod matching;
|
||||
pub mod rename;
|
||||
pub mod web;
|
||||
|
||||
use rhai::Dynamic;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,307 @@
|
||||
//! A small client for [TMDB](https://www.themoviedb.org/)'s v3 API: search for a TV
|
||||
//! show by name, then look up a specific episode's real title/air date.
|
||||
//!
|
||||
//! Ports the *lookup* half of what `net.filebot.web.TheMovieDBClient` did in the
|
||||
//! original; this is intentionally scoped to TV episodes only for v1 (movies are a
|
||||
//! straightforward follow-up using the parallel `/search/movie` + `/movie/{id}`
|
||||
//! endpoints, not yet added). Authenticates with a TMDB **API Read Access Token**
|
||||
//! (the Bearer-token method TMDB documents as the current default) rather than the
|
||||
//! older `api_key` query parameter.
|
||||
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://api.themoviedb.org/3";
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TmdbError {
|
||||
#[error("network error talking to TMDB: {0}")]
|
||||
Network(String),
|
||||
#[error("TMDB API error (status {status}): {message}")]
|
||||
Api { status: u16, message: String },
|
||||
#[error("no TMDB match found for \"{0}\"")]
|
||||
NotFound(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct SearchTvResponse {
|
||||
#[serde(default)]
|
||||
results: Vec<TvResult>,
|
||||
}
|
||||
|
||||
/// One TMDB search hit. Only the fields Renamr actually uses are kept.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TvResult {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct SeasonResponse {
|
||||
#[serde(default)]
|
||||
episodes: Vec<EpisodeResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct EpisodeResult {
|
||||
episode_number: i64,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
air_date: Option<String>,
|
||||
}
|
||||
|
||||
/// Real metadata for one episode, resolved from TMDB - contrast with
|
||||
/// `matching::EpisodeGuess`, which only has whatever a filename pattern implied.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EpisodeInfo {
|
||||
/// TMDB's canonical show name (may differ in punctuation/capitalization from
|
||||
/// whatever the local filename guess produced).
|
||||
pub series_name: String,
|
||||
pub episode_title: String,
|
||||
pub air_date: Option<String>,
|
||||
}
|
||||
|
||||
pub struct TmdbClient {
|
||||
http: reqwest::Client,
|
||||
token: String,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl TmdbClient {
|
||||
pub fn new(token: impl Into<String>) -> Self {
|
||||
Self {
|
||||
http: reqwest::Client::new(),
|
||||
token: token.into(),
|
||||
base_url: DEFAULT_BASE_URL.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides the API base URL - used by tests to point at a local mock server
|
||||
/// instead of the real TMDB API.
|
||||
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||
self.base_url = base_url.into();
|
||||
self
|
||||
}
|
||||
|
||||
async fn get_json<T: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
path: &str,
|
||||
query: &[(&str, &str)],
|
||||
) -> Result<T, TmdbError> {
|
||||
let url = format!("{}{path}", self.base_url);
|
||||
let response = self
|
||||
.http
|
||||
.get(&url)
|
||||
.bearer_auth(&self.token)
|
||||
.query(query)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| TmdbError::Network(e.to_string()))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
let message = response.text().await.unwrap_or_default();
|
||||
return Err(TmdbError::Api { status, message });
|
||||
}
|
||||
|
||||
response
|
||||
.json::<T>()
|
||||
.await
|
||||
.map_err(|e| TmdbError::Network(e.to_string()))
|
||||
}
|
||||
|
||||
/// Searches for a TV show by name. Results come back TMDB-ranked (relevance /
|
||||
/// popularity); callers wanting "the best guess" should just take the first one.
|
||||
pub async fn search_tv(&self, query: &str) -> Result<Vec<TvResult>, TmdbError> {
|
||||
let parsed: SearchTvResponse = self.get_json("/search/tv", &[("query", query)]).await?;
|
||||
Ok(parsed.results)
|
||||
}
|
||||
|
||||
/// Fetches every episode of one season in a single request. Prefer this over
|
||||
/// calling `episode()` per-episode when looking up several episodes from the same
|
||||
/// season (a whole loaded folder is usually one season) - the season endpoint
|
||||
/// already returns the full episode list, so there's no need to re-fetch it per
|
||||
/// episode; cache this call's result and look episodes up from it locally instead.
|
||||
pub async fn season_episodes(&self, series_id: u64, season: i32) -> Result<Vec<SeasonEpisode>, TmdbError> {
|
||||
let path = format!("/tv/{series_id}/season/{season}");
|
||||
let parsed: SeasonResponse = self.get_json(&path, &[]).await?;
|
||||
|
||||
Ok(parsed
|
||||
.episodes
|
||||
.into_iter()
|
||||
.map(|e| SeasonEpisode {
|
||||
episode_number: e.episode_number as i32,
|
||||
title: e.name,
|
||||
air_date: e.air_date,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Looks up one episode's real title/air date from a known TMDB series id. Fetches
|
||||
/// the whole season each call - if you need several episodes from the same season,
|
||||
/// call `season_episodes()` once instead and look them up locally.
|
||||
pub async fn episode(&self, series_id: u64, season: i32, episode: i32) -> Result<EpisodeResultInfo, TmdbError> {
|
||||
let episodes = self.season_episodes(series_id, season).await?;
|
||||
episodes
|
||||
.into_iter()
|
||||
.find(|e| e.episode_number == episode)
|
||||
.map(|e| EpisodeResultInfo {
|
||||
episode_title: e.title,
|
||||
air_date: e.air_date,
|
||||
})
|
||||
.ok_or_else(|| TmdbError::NotFound(format!("S{season:02}E{episode:02}")))
|
||||
}
|
||||
|
||||
/// Convenience: search for the best-matching show by name, then fetch a specific
|
||||
/// episode. Picks the first search result - good enough for v1 since TMDB already
|
||||
/// ranks by relevance; a proper fuzzy-matching disambiguation pass (porting
|
||||
/// `EpisodeMetrics`) is still on the roadmap for when a search returns multiple
|
||||
/// plausible shows.
|
||||
pub async fn lookup_episode(
|
||||
&self,
|
||||
series_query: &str,
|
||||
season: i32,
|
||||
episode: i32,
|
||||
) -> Result<EpisodeInfo, TmdbError> {
|
||||
let results = self.search_tv(series_query).await?;
|
||||
let best = results
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| TmdbError::NotFound(series_query.to_string()))?;
|
||||
|
||||
let info = self.episode(best.id, season, episode).await?;
|
||||
Ok(EpisodeInfo {
|
||||
series_name: best.name,
|
||||
episode_title: info.episode_title,
|
||||
air_date: info.air_date,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Just the episode-specific fields, before the series name (known by the caller of
|
||||
/// `episode()`, which already has a series id) is attached.
|
||||
pub struct EpisodeResultInfo {
|
||||
pub episode_title: String,
|
||||
pub air_date: Option<String>,
|
||||
}
|
||||
|
||||
/// One episode of a season, as returned by `season_episodes()`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SeasonEpisode {
|
||||
pub episode_number: i32,
|
||||
pub title: String,
|
||||
pub air_date: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn looks_up_a_real_episode_end_to_end() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _search_mock = server
|
||||
.mock("GET", "/search/tv")
|
||||
.match_query(mockito::Matcher::UrlEncoded("query".into(), "Breaking Bad".into()))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"results":[{"id":1396,"name":"Breaking Bad"}]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let _season_mock = server
|
||||
.mock("GET", "/tv/1396/season/5")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"episodes":[
|
||||
{"episode_number":13,"name":"To'hajiilee","air_date":"2013-09-08"},
|
||||
{"episode_number":14,"name":"Ozymandias","air_date":"2013-09-15"}
|
||||
]}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TmdbClient::new("test-token").with_base_url(server.url());
|
||||
let info = client.lookup_episode("Breaking Bad", 5, 14).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
info,
|
||||
EpisodeInfo {
|
||||
series_name: "Breaking Bad".to_string(),
|
||||
episode_title: "Ozymandias".to_string(),
|
||||
air_date: Some("2013-09-15".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reports_not_found_when_search_has_no_results() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _mock = server
|
||||
.mock("GET", "/search/tv")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"results":[]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TmdbClient::new("test-token").with_base_url(server.url());
|
||||
let result = client.lookup_episode("Some Nonexistent Show", 1, 1).await;
|
||||
|
||||
assert!(matches!(result, Err(TmdbError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reports_not_found_when_the_season_has_no_such_episode() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _search_mock = server
|
||||
.mock("GET", "/search/tv")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"results":[{"id":42,"name":"Some Show"}]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let _season_mock = server
|
||||
.mock("GET", "/tv/42/season/1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"episodes":[{"episode_number":1,"name":"Pilot","air_date":"2020-01-01"}]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TmdbClient::new("test-token").with_base_url(server.url());
|
||||
// Season 1 only has episode 1 in this mock - asking for episode 99 should miss.
|
||||
let result = client.lookup_episode("Some Show", 1, 99).await;
|
||||
|
||||
assert!(matches!(result, Err(TmdbError::NotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn surfaces_an_invalid_token_as_an_api_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _mock = server
|
||||
.mock("GET", "/search/tv")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(401)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"status_message":"Invalid API key: You must be granted a valid key.","status_code":7}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = TmdbClient::new("bad-token").with_base_url(server.url());
|
||||
let result = client.search_tv("anything").await;
|
||||
|
||||
match result {
|
||||
Err(TmdbError::Api { status, .. }) => assert_eq!(status, 401),
|
||||
other => panic!("expected Api error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! The Tauri-facing side of "Fetch Data": takes the local season/episode guesses the
|
||||
//! UI already has (from `guess_episode`/Match) and resolves them against real TMDB
|
||||
//! metadata. Deliberately layered on top of local matching rather than replacing it -
|
||||
//! Fetch Data needs a series name and season/episode to search with, which is exactly
|
||||
//! what Match already produces for free, offline.
|
||||
|
||||
use renamr_core::web::tmdb::{SeasonEpisode, TmdbClient};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::settings;
|
||||
|
||||
/// One file's local guess, sent up from the UI to be resolved against TMDB.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FetchQuery {
|
||||
/// Echoed back in the result so the frontend can map it back to the right item.
|
||||
pub id: String,
|
||||
pub series_query: String,
|
||||
pub season: i32,
|
||||
pub episode: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
pub enum FetchOutcome {
|
||||
Found {
|
||||
series_name: String,
|
||||
episode_title: String,
|
||||
air_date: Option<String>,
|
||||
},
|
||||
/// Either nothing on TMDB matched the series name, or the show was found but that
|
||||
/// season doesn't have this episode number.
|
||||
NotFound,
|
||||
Failed {
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FetchResult {
|
||||
pub id: String,
|
||||
pub outcome: FetchOutcome,
|
||||
}
|
||||
|
||||
/// Resolves a batch of local guesses against TMDB. Runs off the main thread since it's
|
||||
/// doing real network I/O; not `blocking_*`-style Tauri API, so no deadlock risk like
|
||||
/// the dialog commands had - `spawn` here is just to keep it explicitly off the
|
||||
/// command-invocation path some Tauri internals run on.
|
||||
#[tauri::command]
|
||||
pub async fn fetch_metadata(app: AppHandle, items: Vec<FetchQuery>) -> Vec<FetchResult> {
|
||||
let Some(token) = settings::tmdb_token(&app) else {
|
||||
return items
|
||||
.into_iter()
|
||||
.map(|item| FetchResult {
|
||||
id: item.id,
|
||||
outcome: FetchOutcome::Failed {
|
||||
error: "No TMDB API token configured — open Settings to add one.".to_string(),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
};
|
||||
|
||||
tauri::async_runtime::spawn(run_fetch(token, items))
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
type SeasonLookup = Result<(String, Vec<SeasonEpisode>), String>;
|
||||
|
||||
async fn run_fetch(token: String, items: Vec<FetchQuery>) -> Vec<FetchResult> {
|
||||
let client = TmdbClient::new(token);
|
||||
|
||||
// Cache per (series_query, season): a whole loaded season's worth of files only
|
||||
// needs one search + one season-episode-list fetch, not one per file.
|
||||
let mut season_cache: HashMap<(String, i32), SeasonLookup> = HashMap::new();
|
||||
let mut results = Vec::with_capacity(items.len());
|
||||
|
||||
for item in items {
|
||||
let key = (item.series_query.clone(), item.season);
|
||||
|
||||
if !season_cache.contains_key(&key) {
|
||||
let looked_up = fetch_season(&client, &item.series_query, item.season).await;
|
||||
season_cache.insert(key.clone(), looked_up);
|
||||
}
|
||||
|
||||
let outcome = match season_cache.get(&key).expect("just inserted") {
|
||||
Ok((series_name, episodes)) => match episodes.iter().find(|e| e.episode_number == item.episode) {
|
||||
Some(found) => FetchOutcome::Found {
|
||||
series_name: series_name.clone(),
|
||||
episode_title: found.title.clone(),
|
||||
air_date: found.air_date.clone(),
|
||||
},
|
||||
None => FetchOutcome::NotFound,
|
||||
},
|
||||
Err(error) => FetchOutcome::Failed { error: error.clone() },
|
||||
};
|
||||
|
||||
results.push(FetchResult { id: item.id, outcome });
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
async fn fetch_season(client: &TmdbClient, series_query: &str, season: i32) -> SeasonLookup {
|
||||
let search_results = client.search_tv(series_query).await.map_err(|e| e.to_string())?;
|
||||
let best = search_results
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| format!("no TMDB match for \"{series_query}\""))?;
|
||||
let episodes = client
|
||||
.season_episodes(best.id, season)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok((best.name, episodes))
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Small persisted app settings - currently just the TMDB API token ("Fetch Data" needs
|
||||
//! it; everything else in the app works offline without any settings at all). Stored as
|
||||
//! plain JSON in Tauri's per-OS app config directory (e.g.
|
||||
//! `~/Library/Application Support/com.renamr.app/settings.json` on macOS,
|
||||
//! `~/.config/renamr/settings.json` on Linux) - not the original FileBot's config
|
||||
//! location, since that's Java Preferences-backed and OS/JVM-specific; this is Renamr's
|
||||
//! own, analogous location for its own settings.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct StoredSettings {
|
||||
#[serde(default)]
|
||||
tmdb_token: Option<String>,
|
||||
}
|
||||
|
||||
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.map_err(|e| format!("couldn't resolve the app config directory: {e}"))?;
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("couldn't create {}: {e}", dir.display()))?;
|
||||
Ok(dir.join("settings.json"))
|
||||
}
|
||||
|
||||
fn load(app: &AppHandle) -> StoredSettings {
|
||||
let Ok(path) = settings_path(app) else {
|
||||
return StoredSettings::default();
|
||||
};
|
||||
fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|contents| serde_json::from_str(&contents).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save(app: &AppHandle, settings: &StoredSettings) -> Result<(), String> {
|
||||
let path = settings_path(app)?;
|
||||
let json = serde_json::to_string_pretty(settings).map_err(|e| e.to_string())?;
|
||||
fs::write(&path, json).map_err(|e| format!("couldn't write {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Returns the stored TMDB token, or `None` if nothing (or only blank text) is saved.
|
||||
pub fn tmdb_token(app: &AppHandle) -> Option<String> {
|
||||
load(app).tmdb_token.filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_tmdb_token(app: AppHandle) -> Option<String> {
|
||||
tmdb_token(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_tmdb_token(app: AppHandle, token: String) -> Result<(), String> {
|
||||
let mut settings = load(&app);
|
||||
let trimmed = token.trim().to_string();
|
||||
settings.tmdb_token = if trimmed.is_empty() { None } else { Some(trimmed) };
|
||||
save(&app, &settings)
|
||||
}
|
||||
@@ -144,3 +144,12 @@ export function FetchIcon() {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyIcon() {
|
||||
return (
|
||||
<svg {...{ ...base, width: 15, height: 15 }}>
|
||||
<circle cx="8" cy="15" r="4" />
|
||||
<path d="M11 12l9-9M16 5l3 3M13 8l3 3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
FetchIcon,
|
||||
GearIcon,
|
||||
HistoryIcon,
|
||||
KeyIcon,
|
||||
LoadIcon,
|
||||
PresetsIcon,
|
||||
RemoveIcon,
|
||||
@@ -61,6 +62,28 @@ interface RenameResult {
|
||||
outcome: RenameOutcome;
|
||||
}
|
||||
|
||||
// Sent to `fetch_metadata`: one file's local guess (from Match) to resolve against
|
||||
// real TMDB data. Needs a series name + season/episode to search with - exactly what
|
||||
// Match already produces offline, which is why Fetch Data requires Match to have run
|
||||
// first rather than doing its own from-scratch identification.
|
||||
interface FetchQuery {
|
||||
id: string;
|
||||
seriesQuery: string;
|
||||
season: number;
|
||||
episode: number;
|
||||
}
|
||||
|
||||
// Mirrors renamr-core's `FetchOutcome` (serde-tagged on "status").
|
||||
type FetchOutcome =
|
||||
| { status: "found"; seriesName: string; episodeTitle: string; airDate: string | null }
|
||||
| { status: "notFound" }
|
||||
| { status: "failed"; error: string };
|
||||
|
||||
interface FetchResult {
|
||||
id: string;
|
||||
outcome: FetchOutcome;
|
||||
}
|
||||
|
||||
function fileNameFromPath(path: string): string {
|
||||
const parts = path.split(/[\\/]/);
|
||||
return parts[parts.length - 1] ?? path;
|
||||
@@ -122,13 +145,15 @@ function ListButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
icon: JSX.Element;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button className="list-icon-button" title={label} onClick={onClick}>
|
||||
<button className="list-icon-button" title={label} onClick={onClick} disabled={disabled}>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
@@ -186,6 +211,20 @@ export default function RenamePanel() {
|
||||
const [renamePlan, setRenamePlan] = useState<RenamePlanEntry[] | null>(null);
|
||||
const [renamePlanSkipped, setRenamePlanSkipped] = useState(0);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
// TMDB token state for "Fetch Data". `null` = not checked yet, `false` = checked and
|
||||
// nothing saved, `true` = a token is on file (the value itself never comes back to
|
||||
// the frontend after saving - see Settings modal below).
|
||||
const [hasTmdbToken, setHasTmdbToken] = useState<boolean | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [tmdbTokenInput, setTmdbTokenInput] = useState("");
|
||||
const [savingSettings, setSavingSettings] = useState(false);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
invoke<string | null>("get_tmdb_token")
|
||||
.then((token) => setHasTmdbToken(!!token))
|
||||
.catch(() => setHasTmdbToken(false));
|
||||
}, []);
|
||||
|
||||
// Refs so the drag-and-drop listener (registered once on mount) always sees the
|
||||
// latest expression/items instead of the stale values from its first render.
|
||||
@@ -466,6 +505,125 @@ export default function RenamePanel() {
|
||||
setRenamePlan(null);
|
||||
};
|
||||
|
||||
const openSettings = () => {
|
||||
setTmdbTokenInput("");
|
||||
setSettingsOpen(true);
|
||||
};
|
||||
|
||||
const saveTmdbToken = async () => {
|
||||
const trimmed = tmdbTokenInput.trim();
|
||||
if (!trimmed) {
|
||||
// Nothing typed - just close, leaving whatever was already saved untouched.
|
||||
setSettingsOpen(false);
|
||||
return;
|
||||
}
|
||||
setSavingSettings(true);
|
||||
try {
|
||||
await invoke("save_tmdb_token", { token: trimmed });
|
||||
setHasTmdbToken(true);
|
||||
setTmdbTokenInput("");
|
||||
setSettingsOpen(false);
|
||||
setStatus("TMDB token saved.");
|
||||
window.clearTimeout((saveTmdbToken as any)._t);
|
||||
(saveTmdbToken as any)._t = window.setTimeout(() => setStatus(null), 3000);
|
||||
} catch (err) {
|
||||
setStatus(`Couldn't save the token: ${err}`);
|
||||
} finally {
|
||||
setSavingSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearTmdbToken = async () => {
|
||||
setSavingSettings(true);
|
||||
try {
|
||||
await invoke("save_tmdb_token", { token: "" });
|
||||
setHasTmdbToken(false);
|
||||
setTmdbTokenInput("");
|
||||
setStatus("TMDB token removed.");
|
||||
window.clearTimeout((clearTmdbToken as any)._t);
|
||||
(clearTmdbToken as any)._t = window.setTimeout(() => setStatus(null), 3000);
|
||||
} catch (err) {
|
||||
setStatus(`Couldn't remove the token: ${err}`);
|
||||
} finally {
|
||||
setSavingSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
// "Fetch Data": resolve each already-Matched file's local guess against real TMDB
|
||||
// metadata. Requires Match to have already populated a series name + season/episode
|
||||
// (that's the search query) - this is deliberately layered on top of local matching,
|
||||
// not a replacement for it.
|
||||
const runFetch = async () => {
|
||||
if (hasTmdbToken === false) {
|
||||
setStatus("Add a free TMDB API token in Settings first, then Fetch Data will work.");
|
||||
setSettingsOpen(true);
|
||||
window.clearTimeout((runFetch as any)._t);
|
||||
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
||||
return;
|
||||
}
|
||||
|
||||
const targets = items.filter((i) => !i.isSample && i.bindings?.n && i.bindings?.season && i.bindings?.episode);
|
||||
if (targets.length === 0) {
|
||||
setStatus("Run Match first — Fetch Data needs a guessed series name and season/episode to look up.");
|
||||
window.clearTimeout((runFetch as any)._t);
|
||||
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 4000);
|
||||
return;
|
||||
}
|
||||
|
||||
setFetching(true);
|
||||
try {
|
||||
const query: FetchQuery[] = targets.map((item) => ({
|
||||
id: item.id,
|
||||
seriesQuery: item.bindings!.n,
|
||||
season: Number(item.bindings!.season),
|
||||
episode: Number(item.bindings!.episode),
|
||||
}));
|
||||
const results = await invoke<FetchResult[]>("fetch_metadata", { items: query });
|
||||
const byId = new Map(results.map((r) => [r.id, r] as const));
|
||||
|
||||
// Compute counts from `results` directly, not inside the setItems updater below -
|
||||
// same rule as runMatching/confirmRename above (React doesn't run a functional
|
||||
// updater synchronously, so a counter mutated in there wouldn't be ready yet).
|
||||
const found = results.filter((r) => r.outcome.status === "found").length;
|
||||
const notFound = results.filter((r) => r.outcome.status === "notFound").length;
|
||||
const failed = results.filter((r) => r.outcome.status === "failed").length;
|
||||
|
||||
setItems((prev) =>
|
||||
prev.map((item) => {
|
||||
const result = byId.get(item.id);
|
||||
if (!result || result.outcome.status !== "found") return item;
|
||||
const { seriesName, episodeTitle, airDate } = result.outcome;
|
||||
return {
|
||||
...item,
|
||||
bindings: {
|
||||
...item.bindings,
|
||||
n: seriesName,
|
||||
t: episodeTitle,
|
||||
...(airDate ? { airdate: airDate } : {}),
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Showcase the episode title field, but only the first time and only if the user
|
||||
// hasn't already customized the naming scheme away from the Match-set default.
|
||||
if (expressionRef.current === "{n} - {s00e00}" && found > 0) {
|
||||
setExpression("{n} - {s00e00} - {t}");
|
||||
}
|
||||
|
||||
const parts = [`Fetched real metadata for ${found} of ${targets.length} file${targets.length === 1 ? "" : "s"}`];
|
||||
if (notFound > 0) parts.push(`${notFound} not found on TMDB`);
|
||||
if (failed > 0) parts.push(`${failed} failed`);
|
||||
setStatus(parts.join(", ") + ".");
|
||||
} catch (err) {
|
||||
setStatus(`Fetch failed: ${err}`);
|
||||
} finally {
|
||||
setFetching(false);
|
||||
window.clearTimeout((runFetch as any)._t);
|
||||
(runFetch as any)._t = window.setTimeout(() => setStatus(null), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const removeSelected = () => {
|
||||
if (!selected) return notImplemented("Excluding items");
|
||||
setItems((prev) => {
|
||||
@@ -552,7 +710,8 @@ export default function RenamePanel() {
|
||||
<ListButton icon={<UpIcon />} label="Align Up" onClick={() => notImplemented("Reordering")} />
|
||||
<LoadMenuButton onFiles={loadFiles} onFolder={loadFolder} />
|
||||
<span className="list-button-gap" />
|
||||
<ListButton icon={<FetchIcon />} label="Fetch Data" onClick={() => notImplemented("Online matching")} />
|
||||
<ListButton icon={<FetchIcon />} label="Fetch Data" onClick={runFetch} disabled={fetching} />
|
||||
<ListButton icon={<KeyIcon />} label="TMDB Settings" onClick={openSettings} />
|
||||
<ListButton icon={<GearIcon />} label="Naming Scheme" onClick={() => setEditingFormat((v) => !v)} />
|
||||
</>
|
||||
}
|
||||
@@ -574,8 +733,9 @@ export default function RenamePanel() {
|
||||
Rust naming-expression engine (Rhai-backed) through Tauri. Loaded files start with only{" "}
|
||||
<code>{"{name}"}</code>/<code>{"{ext}"}</code> (their own filename) — click <strong>Match</strong> to scan
|
||||
filenames locally for season/episode patterns and populate <code>{"{n}"}</code>/<code>{"{s00e00}"}</code>/
|
||||
<code>{"{season}"}</code>/<code>{"{episode}"}</code>. That's filename parsing only, no network calls;{" "}
|
||||
<strong>Fetch Data</strong> (online metadata matching) isn't built yet.
|
||||
<code>{"{season}"}</code>/<code>{"{episode}"}</code> (no network calls), then <strong>Fetch Data</strong> to
|
||||
resolve those guesses against real TMDB metadata and populate <code>{"{t}"}</code> (episode title) and{" "}
|
||||
<code>{"{airdate}"}</code> too — needs a free TMDB API token set in <strong>TMDB Settings</strong> first.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -619,6 +779,53 @@ export default function RenamePanel() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settingsOpen && (
|
||||
<div className="modal-overlay" onClick={() => !savingSettings && setSettingsOpen(false)}>
|
||||
<div className="modal-card modal-card-compact" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="modal-title">TMDB Settings</h2>
|
||||
<p className="modal-subtitle">
|
||||
Fetch Data uses{" "}
|
||||
<a href="https://www.themoviedb.org/" target="_blank" rel="noreferrer">
|
||||
TheMovieDB
|
||||
</a>{" "}
|
||||
to resolve local guesses into real episode titles and air dates. Get a free{" "}
|
||||
<strong>API Read Access Token</strong> from your TMDB account under Settings → API, then paste it below.
|
||||
The token is stored locally on this machine and never leaves it except to call TMDB directly.
|
||||
</p>
|
||||
<label htmlFor="tmdb-token" className="modal-field-label">
|
||||
API Read Access Token
|
||||
</label>
|
||||
<input
|
||||
id="tmdb-token"
|
||||
className="modal-text-input"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={tmdbTokenInput}
|
||||
onChange={(e) => setTmdbTokenInput(e.target.value)}
|
||||
placeholder={hasTmdbToken ? "•••••••••• (already saved — enter a new token to replace it)" : "Paste your TMDB token here"}
|
||||
/>
|
||||
<div className="modal-actions modal-actions-split">
|
||||
{hasTmdbToken ? (
|
||||
<button className="action-button" onClick={clearTmdbToken} disabled={savingSettings}>
|
||||
Remove Token
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="modal-actions">
|
||||
<button className="action-button" onClick={() => setSettingsOpen(false)} disabled={savingSettings}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="action-button primary" onClick={saveTmdbToken} disabled={savingSettings}>
|
||||
{savingSettings ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user