From 697b009627b982f4203faa070421f5ec760177fd Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 16 Jul 2026 22:22:53 +0800 Subject: [PATCH] can't be bothered writing a commit message --- .gitignore | 5 + Cargo.lock | 3495 +++++++++++++++++ Cargo.toml | 27 + LICENSE | 21 + README.md | 106 + bakery.toml | 21 + breadarr-shared/Cargo.toml | 11 + breadarr-shared/src/client.rs | 371 ++ breadarr-shared/src/config.rs | 375 ++ breadarr-shared/src/dto.rs | 265 ++ breadarr-shared/src/lib.rs | 6 + breadarr-tui/Cargo.toml | 14 + breadarr-tui/src/app.rs | 792 ++++ breadarr-tui/src/main.rs | 187 + breadarr-tui/src/ui.rs | 559 +++ breadarrd/Cargo.toml | 25 + breadarrd/src/api/mod.rs | 180 + breadarrd/src/api/routes/calendar.rs | 52 + breadarrd/src/api/routes/health.rs | 25 + breadarrd/src/api/routes/library_health.rs | 321 ++ breadarrd/src/api/routes/media.rs | 507 +++ breadarrd/src/api/routes/mod.rs | 9 + breadarrd/src/api/routes/quality_profiles.rs | 87 + breadarrd/src/api/routes/releases.rs | 38 + breadarrd/src/api/routes/review.rs | 108 + breadarrd/src/api/routes/search.rs | 63 + breadarrd/src/api/routes/stuck.rs | 84 + breadarrd/src/db.rs | 615 +++ breadarrd/src/importer/ffprobe.rs | 379 ++ breadarrd/src/importer/mkv.rs | 94 + breadarrd/src/importer/mod.rs | 3651 ++++++++++++++++++ breadarrd/src/jellyfin.rs | 41 + breadarrd/src/library_scan.rs | 861 +++++ breadarrd/src/main.rs | 1208 ++++++ breadarrd/src/matcher/embed.rs | 160 + breadarrd/src/matcher/mod.rs | 278 ++ breadarrd/src/metadata/anime_map.rs | 200 + breadarrd/src/metadata/mod.rs | 148 + breadarrd/src/metadata/tmdb.rs | 145 + breadarrd/src/metadata/tvdb.rs | 162 + breadarrd/src/notify.rs | 70 + breadarrd/src/parser/mod.rs | 291 ++ breadarrd/src/parser/tokens.rs | 206 + breadarrd/src/qbit/mod.rs | 265 ++ breadarrd/src/scheduler.rs | 3065 +++++++++++++++ breadarrd/src/scoring/gate.rs | 262 ++ breadarrd/src/scoring/mod.rs | 7 + breadarrd/src/scoring/profile.rs | 211 + breadarrd/src/scoring/score.rs | 165 + breadarrd/src/sources/mod.rs | 83 + breadarrd/src/sources/rss.rs | 196 + breadarrd/src/sources/scrape.rs | 549 +++ breadarrd/src/sources/tpb.rs | 132 + config.example.toml | 99 + packaging/systemd/breadarrd.service | 23 + 55 files changed, 21320 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 bakery.toml create mode 100644 breadarr-shared/Cargo.toml create mode 100644 breadarr-shared/src/client.rs create mode 100644 breadarr-shared/src/config.rs create mode 100644 breadarr-shared/src/dto.rs create mode 100644 breadarr-shared/src/lib.rs create mode 100644 breadarr-tui/Cargo.toml create mode 100644 breadarr-tui/src/app.rs create mode 100644 breadarr-tui/src/main.rs create mode 100644 breadarr-tui/src/ui.rs create mode 100644 breadarrd/Cargo.toml create mode 100644 breadarrd/src/api/mod.rs create mode 100644 breadarrd/src/api/routes/calendar.rs create mode 100644 breadarrd/src/api/routes/health.rs create mode 100644 breadarrd/src/api/routes/library_health.rs create mode 100644 breadarrd/src/api/routes/media.rs create mode 100644 breadarrd/src/api/routes/mod.rs create mode 100644 breadarrd/src/api/routes/quality_profiles.rs create mode 100644 breadarrd/src/api/routes/releases.rs create mode 100644 breadarrd/src/api/routes/review.rs create mode 100644 breadarrd/src/api/routes/search.rs create mode 100644 breadarrd/src/api/routes/stuck.rs create mode 100644 breadarrd/src/db.rs create mode 100644 breadarrd/src/importer/ffprobe.rs create mode 100644 breadarrd/src/importer/mkv.rs create mode 100644 breadarrd/src/importer/mod.rs create mode 100644 breadarrd/src/jellyfin.rs create mode 100644 breadarrd/src/library_scan.rs create mode 100644 breadarrd/src/main.rs create mode 100644 breadarrd/src/matcher/embed.rs create mode 100644 breadarrd/src/matcher/mod.rs create mode 100644 breadarrd/src/metadata/anime_map.rs create mode 100644 breadarrd/src/metadata/mod.rs create mode 100644 breadarrd/src/metadata/tmdb.rs create mode 100644 breadarrd/src/metadata/tvdb.rs create mode 100644 breadarrd/src/notify.rs create mode 100644 breadarrd/src/parser/mod.rs create mode 100644 breadarrd/src/parser/tokens.rs create mode 100644 breadarrd/src/qbit/mod.rs create mode 100644 breadarrd/src/scheduler.rs create mode 100644 breadarrd/src/scoring/gate.rs create mode 100644 breadarrd/src/scoring/mod.rs create mode 100644 breadarrd/src/scoring/profile.rs create mode 100644 breadarrd/src/scoring/score.rs create mode 100644 breadarrd/src/sources/mod.rs create mode 100644 breadarrd/src/sources/rss.rs create mode 100644 breadarrd/src/sources/scrape.rs create mode 100644 breadarrd/src/sources/tpb.rs create mode 100644 config.example.toml create mode 100644 packaging/systemd/breadarrd.service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2594e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target +config.toml +*.db +*.db-wal +*.db-shm diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ce77140 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3495 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "breadarr-shared" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "reqwest", + "serde", + "toml", +] + +[[package]] +name = "breadarr-tui" +version = "0.1.0" +dependencies = [ + "anyhow", + "breadarr-shared", + "chrono", + "crossterm", + "ratatui", + "reqwest", + "serde", + "tokio", +] + +[[package]] +name = "breadarrd" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "breadarr-shared", + "chrono", + "fastrand", + "nix", + "ort", + "quick-xml", + "regex", + "reqwest", + "rusqlite", + "scraper", + "serde", + "serde_json", + "tokenizers", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "cssparser" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3df4f93e5fbbe73ec01ec8d3f68bba73107993a5b1e7519273c32db9b0d5be" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.11.3", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "daachorse" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f55d7153ba3b507595872a3874803f07a8a81d1e888abed8e5db7da0597d6e2" + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn", +] + +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "ego-tree" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12a0bb14ac04a9fcf170d0bbbef949b44cc492f4452bd20c095636956f653642" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" +dependencies = [ + "cc", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width 0.2.2", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hmac-sha256" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" + +[[package]] +name = "html5ever" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +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.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "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", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indicatif" +version = "0.18.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" +dependencies = [ + "console", + "portable-atomic", + "unicode-width 0.2.2", + "unit-prefix", + "web-time", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "lzma-rust2" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "macro_rules_attribute" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" +dependencies = [ + "macro_rules_attribute-proc_macro", + "paste", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" + +[[package]] +name = "markup5ever" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "onig" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +dependencies = [ + "bitflags", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ort" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", + "ureq", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" +dependencies = [ + "hmac-sha256", + "lzma-rust2", + "ureq", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_shared 0.10.0", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.7", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.7", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.3", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[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.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[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 = "ratatui" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" +dependencies = [ + "bitflags", + "cassowary", + "compact_str 0.8.2", + "crossterm", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum", + "strum_macros", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.1.14", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools 0.14.0", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[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", + "cookie", + "cookie_store", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[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 = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +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 = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scraper" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90460b31bfe1fc07be8262e42c665ad97118d4585869de9345a84d501a9eaf0" +dependencies = [ + "ahash", + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "once_cell", + "selectors", + "tendril", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06" +dependencies = [ + "bitflags", + "cssparser", + "derive_more", + "fxhash", + "log", + "new_debug_unreachable", + "phf 0.10.1", + "phf_codegen 0.10.0", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[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 = "servo_arc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d036d71a959e00c77a63538b90a6c2390969f9772b096ea837205c6bd0491a44" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokenizers" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44e5bea67576e04b6ff8564c5d9e09c2ef0cf476502245f2f120e497769d3112" +dependencies = [ + "ahash", + "compact_str 0.9.1", + "daachorse", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", + "indicatif", + "itertools 0.14.0", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand 0.9.5", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "der", + "log", + "native-tls", + "percent-encoding", + "rustls-pki-types", + "socks", + "ureq-proto", + "utf8-zero", + "webpki-root-certs", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9bab6de --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,27 @@ +[workspace] +members = ["breadarr-shared", "breadarrd", "breadarr-tui"] +resolver = "2" + +[workspace.dependencies] +breadarr-shared = { path = "breadarr-shared" } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +axum = { version = "0.7", features = ["macros"] } +reqwest = { version = "0.12", features = ["json", "cookies", "multipart"] } +chrono = { version = "0.4", features = ["serde"] } +ratatui = "0.28" +crossterm = "0.28" +rusqlite = { version = "0.31", features = ["bundled"] } +quick-xml = "0.41" +async-trait = "0.1" +regex = "1" +scraper = "0.20" +ort = "2.0.0-rc.12" +tokenizers = "0.23" +fastrand = "2" +nix = { version = "0.29", features = ["fs"] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..373e4ee --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Breadway + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..efa64cc --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# breadarr + +A single-daemon Rust replacement for the Sonarr + Radarr + Prowlarr stack — one process, one database, and a small, opinionated set of behaviors instead of a fully general, endlessly-pluggable indexer/automation platform. If Sonarr/Radarr/Prowlarr's flexibility is more than you need and you're fine with a small hardcoded set of sources and a terminal UI in exchange for a much lighter footprint, this is built for you. + +- **`breadarrd`** — the daemon. Watches sources, matches releases to your library, scores and grabs candidates, imports completed downloads, probes them for real ground-truth quality, and refreshes Jellyfin. Runs as a `systemd --user` service. +- **`breadarr-tui`** — a terminal client (ratatui) that talks to `breadarrd`'s local HTTP API. No web UI, on purpose. + +## Why this exists + +Sonarr + Radarr + Prowlarr is three separate services, three databases, three web UIs, and a lot of setup surface for one workflow: watch for releases, pick the best one, download it, file it correctly, tell Jellyfin. breadarr collapses that into a single daemon, with a few specific problems solved directly rather than configured around: + +- **Wrong default audio track** — a common pattern on some trackers is "Italian track 1, English track 2." breadarr detects this after download and remuxes the English track to default via `mkvmerge`, instead of scoring the release down or grabbing something worse. The same fix can be swept across the *existing* library after the fact (`remux-backlog`), not just applied to new grabs. +- **Anime numbering** — absolute episode numbers get resolved to season/episode via a bundled AniDB↔TVDB↔TMDB mapping, and anime is allowed to fall back to Japanese-only audio when no English release exists (every other content gate requires English audio). +- **Fuzzy title matching** — release titles are matched to your monitored library via a local ONNX embedding model (`all-MiniLM-L6-v2`, CPU-only, no GPU dependency), not exact-string matching. Low-confidence matches land in a review queue instead of silently grabbing the wrong show. A token-overlap sanity gate also blocks the model's one known failure mode (see [Known limitations](#known-limitations)) from reaching the review queue at all, not just from auto-matching. +- **Library normalization** — a library-scan mode matches existing folders to TVDB/TMDB and renames them down to a clean `Title (Year)` form, stripping release-group tags, quality markers, and season/episode cruft that shouldn't be in a folder name. +- **Season packs** — a batch release (a whole season, a multi-episode range) is grabbed and split correctly: each file inside is matched to its own tracked episode by re-parsing its own filename, then imported individually, with quality compared per-episode against whatever's already owned. +- **Media intelligence** — every library file gets `ffprobe`d on import (and, incrementally, in the background for the existing library), so breadarr knows its actual resolution/codec/audio/subtitle makeup — not just what the release title claimed — and can flag files that don't match up. +- **Quality upgrades keep happening after import** — a separate, slow-cadence background cycle periodically re-checks already-owned, monitored episodes/movies against what's currently searchable, and re-grabs when a candidate beats the current file's score by more than a configurable margin (repacks/propers always supersede regardless of margin). The existing hardlink-swap-on-import logic handles the actual file replacement, so this is purely a new trigger, on its own interval and budget (`upgrade_*` in `[sources]`) so it never competes with missing-content search for request budget. Its limited per-cycle budget is spent on `media_file_probe`-flagged files first (verified under-quality, missing subtitles, non-English default audio, or a probe/decode failure) before it's spent uniformly across everything else owned. +- **Manual release picker** — for the cases auto-grab and the review queue don't handle well, press `c` on a selected show/episode/movie in the TUI to see the same scored (or gate-rejected, with the reason) candidate list the automatic pipeline would have seen, and grab one by hand. + +## Sources + +- **nyaa.si** (RSS) — anime TV, polled continuously. Full-auto, no request budget concerns (it's a plain RSS feed). +- **apibay.org** (a community JSON API mirror of The Pirate Bay's search) — the primary search-driven source for general TV and movies. Unlike 1337x this is a genuine machine-readable API, needs no HTML scraping, and its search actually ranks by relevance rather than pure seeder count, which matters a lot for titles made of common words. +- **1337x** (scraped HTML, via community mirrors) — secondary search-driven source, tried after TPB. 1337x's main domain is Cloudflare-protected and has a ban history, so requests are round-robined across mirrors with automatic cooldown/backoff on failures, jittered between searches, and rate-limit responses are honored explicitly. +- **nyaa.si search mode** — anime movies specifically route here instead of TPB/1337x, since nyaa is the safe, official-RSS-interface target and gives materially better results for anime content. + +All three search-driven sources share one per-cycle request budget (default: 5 searches per 30-minute cycle, TPB tried first, then 1337x, then nyaa search) — kept conservative since 1337x has a ban history and the goal is steady backlog clearing, not maximum throughput. A whole cycle failing outright backs off the *next cycle's* interval (1h → 2h → 4h, capped), on top of each source's own per-mirror cooldowns. + +## Setup + +1. Copy `config.example.toml` to `~/.config/breadarr/breadarrd.toml` and fill in: + - qBittorrent WebUI URL/credentials + - Jellyfin URL + API key + - TVDB API key ([thetvdb.com](https://thetvdb.com/dashboard/account/apikeys), "Fan/Personal" tier) + - TMDB API Read Access Token ([themoviedb.org](https://www.themoviedb.org/settings/api), v4 auth, not the shorter v3 key) + - Optionally, a Gotify-shaped `notifications.webhook_url` for push notifications, and `daemon.api_token` if `listen_addr` will ever be bound to more than loopback. +2. Install `mkvtoolnix-cli` and `ffmpeg` on the host (breadarr runs natively, not in Docker). +3. Install the systemd user unit (`packaging/systemd/breadarrd.service`) to `~/.config/systemd/user/`, then: + ``` + loginctl enable-linger $USER # so it runs without an active login session + systemctl --user daemon-reload + systemctl --user enable --now breadarrd + ``` +4. Point `breadarr-tui` at the same config (it reads `daemon.listen_addr` to find the API) and run it. + +## Using the TUI + +`Tab` cycles Library / History / Review Queue / Add Show / Stuck / Calendar / Health. `j`/`k` or arrow keys navigate, `Enter` opens detail or runs a search, `Esc` backs out. + +- **Review Queue** — `a` approves and `r` rejects a pending low-confidence title match. Check this periodically, especially early on. +- **Library** (with an item's detail open) — `s` triggers an immediate search-now pass for that item's backlog; `m`/`e`/`S` toggle monitored on the show/episode/season respectively; `x` (confirm with a second `x`) removes the item from tracking without touching files on disk; `d` (confirm with a second `d`) deletes a bad imported file from disk and clears its tracking, freeing the episode/movie to be re-grabbed on the next cycle — the redownload path for a file that turned out to be wrong or broken; `c` fetches the manual release picker for the selected episode/movie, `Enter` grabs the highlighted candidate, `Esc` cancels. +- **Stuck** — surfaces grabs that look stalled (no download progress advancing, or missing from qBittorrent) before the daemon's own auto-fail timers would catch them. +- **Calendar** — upcoming/recently-aired episodes in a roughly week-either-side window. +- **Health** — the library-health report (see below) rendered as a tab instead of curled by hand. + +## Operational notes + +- `curl http://127.0.0.1:7879/health` reports daemon status plus the last grab/import/search/upgrade cycle outcome — the cheapest way to confirm the automation loop is actually alive. +- `curl http://127.0.0.1:7879/library/health` (or the TUI's Health tab) reports corrupt files, under-1080p files, missing-English-audio files, non-English-default-audio files, missing-subtitle files, duplicate-file groups, and library-wide summary stats (codec breakdown, resolution distribution, subtitle coverage %) — all derived from `ffprobe` data, not release-title claims. +- If `daemon.api_token` is set, every route except `/health` requires `Authorization: Bearer `. +- Library normalization is a manual, explicit action (not run automatically against your files): `breadarrd debug-scan-tv ` / `breadarrd debug-scan-movies `. +- `breadarrd remux-backlog` sweeps the whole library for files with a non-English default audio track and applies the same track-promotion fix used automatically on fresh imports — a one-time (or occasional) pass against files that predate the fix, or were imported before breadarr started tracking them. +- `breadarrd probe-library` backfills `ffprobe` data for the whole library in one run (the running daemon does this incrementally, a bounded batch per hour, so it doesn't stall the grab/import/search cycles — this command is for getting it all done immediately instead). +- `breadarrd verify-library` runs the expensive full-decode corruption check (`ffmpeg -xerror`, actually decoding every frame) against every file whose cheap header probe succeeded but hasn't been decode-verified yet. This is opt-in and can take minutes per file, so — unlike `probe-library` — it's never run automatically by any ticker; run it by hand (or on a cron) whenever you want a real, not-just-header-parseable confirmation the library is intact. +- Other `debug-*` subcommands are diagnostics for exercising one piece of the pipeline directly — run `breadarrd ` with no args to see its usage. Currently: `debug-qbit-add`, `debug-qbit-list`, `debug-jellyfin-refresh`, `debug-tvdb-add`, `debug-tvdb-search`, `debug-anime-map-refresh`, `debug-match-title`, `debug-grab-cycle`, `debug-import-cycle`, `debug-1337x-search`, `debug-scan-tv`, `debug-scan-movies`, `debug-search-show` (a manually-triggered, unthrottled search pass over one already-tracked title's whole backlog), `debug-reconcile-report` (dry-run of the disk-reconciliation pass — safe to run against a freshly-restored or otherwise suspect database before trusting the hourly ticker with it unattended). +- The embedding model (~90MB) downloads automatically on first run. +- The database is backed up (with its WAL/SHM sidecars) to `/backups/` on every daemon startup, keeping the 5 most recent copies — there's no migration framework, so this stands in for the pre-upgrade backup Sonarr/Radarr do on every schema change. +- An hourly background pass reconciles tracked `episode_file` paths against what's actually on disk: a file renamed or transcoded in place (e.g. Tdarr converting codec/container) gets its path repaired rather than being wrongly treated as deleted; a file genuinely gone gets cleared from tracking so it becomes searchable again. A circuit breaker refuses to touch anything if an anomalous fraction of the library looks missing at once (the classic false signal of an offline mount), rather than mass-clearing tracking for files that are actually still there. + +## Known limitations + +- No subtitle generation yet — a Whisper-based reimplementation of an existing external tool is planned (see [Roadmap](#roadmap)), with the schema (`episode_file.subtitle_status`) already reserved for it. +- No Sonarr/Radarr API compatibility shim, so tools expecting that API (e.g. Overseerr/Seerr) can't integrate directly yet — also planned, with an empty `compat/` module reserved so it isn't a retrofit later. +- **Quality-scoring weights are hardcoded, not configurable.** Every axis — resolution tier, source tier, codec tier, bit depth, HDR, repack/proper bonus, and so on — is a fixed constant in `QualityProfile::default_tv`/`default_movie` (`scoring/profile.rs`). The `quality_profile` table and its `weights` JSON column already exist in the schema and are already parsed on load; `scoring/profile.rs` just doesn't read them yet, so every install currently gets the same scoring behavior regardless of what tradeoffs you'd actually prefer (e.g. valuing efficient codecs over raw resolution, or not caring about HDR at all). This is the single biggest gap between "works great for the specific setup it shipped with" and "works well for a range of libraries and preferences" — see [Roadmap](#roadmap). +- The title-matching embedding model (all-MiniLM-L6-v2) doesn't actually discriminate between unrelated romanized-Japanese titles — it clusters any two romaji strings as "similar foreign text" regardless of content (verified live: several unrelated anime auto-matched at >0.85 confidence against a handful of "attractor" shows with zero real relation). A token-overlap gate (`MIN_TOKEN_OVERLAP` in `matcher/mod.rs`) blocks this from both auto-matching *and* reaching the review queue, but the underlying model limitation is a workaround, not a fix. +- The search loop's per-cycle budget is intentionally conservative; raising it trades faster backlog clearing for more request volume against 1337x specifically, which has a ban history. The upgrade-search loop shares the same underlying sources and the same conservatism applies. + +## Roadmap + +Everything above is shipped and running. This section is the honest, disciplined version of "what would this become if taken all the way" — grounded in subsystems that already exist, not a wishlist. Nothing here contradicts the project's two foundational design choices (a small hardcoded set of sources, not a general indexer-plugin architecture; a TUI, not a web UI) — anything that would require reconsidering either is flagged as such. + +### Near-term (extends existing, working subsystems) + +- **Configurable quality-profile weights.** The most user-facing gap in the project today. Different people reasonably want different tradeoffs — some care most about resolution, some would rather have a smaller, more-efficient-codec file, some don't watch anything HDR and don't want it influencing scores at all — and right now every install gets identical hardcoded behavior. The `quality_profile.weights` column already exists in the schema and is already parsed as JSON on load; the gap is purely that `scoring/profile.rs` ignores it in favor of the `default_tv`/`default_movie` constants. Wiring the column through the scoring engine and exposing it as an editable profile in the TUI turns an already-half-built feature into a real one, without changing the scoring model's shape — same axes, same gates, just user-owned numbers instead of fixed ones. + +### Medium-term (closes the loop on data already being collected) + +- **Tdarr hand-off.** `media_file_probe` already has `video_codec`, `container_bitrate`, `video_bitrate`, and `hdr` per file — everything needed to generate a "these files are still H.264/high-bitrate and are good AV1-transcode candidates" list without re-scanning the filesystem. Whether that's a report the user acts on manually or a direct trigger into the existing Tdarr pipeline is a judgment call for whenever this is built — but the *data* side of this is already sitting in the database, unused. +- **Smart-library auto-upgrade daemon.** The combination of the quality-upgrade loop above, corruption detection, and duplicate-group detection (`library/health`'s `duplicate_groups`) is most of what's needed for a low-touch "keep the library clean" background process: re-grab flagged files, quarantine/remove confirmed-corrupt ones, resolve duplicate groups down to the single best-scoring file. This is a composition of pieces above, not a new subsystem — the risk is entirely in getting the "don't touch a file a human hasn't implicitly signed off on" judgment calls right, which argues for notification-first (as the webhook infrastructure already supports) before any auto-delete behavior. +- **Tracker/release-group reliability scoring.** `torrent_fetch` is a permanent, unopinionated log of every torrent ever grabbed, independent of what happened to it afterward — cross-referencing it against `release.status` (`imported` vs `failed`) and `media_file_probe`'s post-import quality flags would let a release-group or per-tracker track record feed back into `scoring/score.rs` as a real weighted axis, instead of only the static `group_allowlist`/`group_denylist` lists that exist today. This is genuinely unexploited surface area — the raw data already exists and nothing reads it. +- **Mining the raw ffprobe JSON.** `media_file_probe.raw_ffprobe_json` is kept verbatim specifically so nothing has to be re-probed when a new use is found for a field that doesn't have its own column yet (chapter markers, encoder tags, less-common stream metadata). The near-term backlog above doesn't need it; whatever comes after does, and it's already there. + +### Longer-term (larger, still-plausible extensions) + +- **Learning from review-queue decisions.** Every approve/reject in the review queue is already a labeled example of "was this match actually correct." Logging outcomes (not just acting on them) and periodically comparing auto-tuned per-library confidence thresholds against the fixed `AUTO_MATCH_CONFIDENCE`/`MIN_TOKEN_OVERLAP` constants in `matcher/mod.rs` is a plausible way to let the matcher get measurably better over time for *this* library's actual title vocabulary — anime is the library segment most exposed to the token-overlap workaround today, so it's also the segment most likely to benefit first. This is a genuine judgment call (a wrong auto-tune silently degrades match quality with no review-queue visibility into it happening), not a slam dunk — worth prototyping as an offline analysis of logged decisions before it's ever allowed to write back to live thresholds. +- **Seerr/Overseerr compatibility shim, then real request fulfillment.** The `compat/` extension point has been reserved from the start for a Sonarr/Radarr v3-API-compatible shim, since that's what Seerr's client code expects. Once that shim exists, the natural next step isn't just protocol compatibility — it's tracking *fulfillment* end-to-end (a request maps to a `media_item`, which maps to `release`/`event_history` rows that already record exactly when and how it was grabbed and imported), giving a requester real status instead of Seerr's own best-effort polling. +- **Whisper subtitle generation.** Fully speced already: reimplement the existing external Python/Whisper tool's exact behavior — "Full Subtitles" (everything transcribed) and "Foreign Parts" (segments where a translate-pass diverges from the transcribe-pass, via text-similarity diff at a 0.80 threshold) SRT tracks, muxed with `mkvmerge` — but gated and on-demand (triggered post-import only when a file lacks subtitles and has non-English/fallback audio), unlike the external tool's full-library batch sweep. `episode_file.subtitle_status` is already wired through the schema for this. The open technical questions are a Rust equivalent of Python's `difflib.SequenceMatcher` for the similarity diff, and which Whisper-in-Rust GPU backend story actually works well across the range of consumer GPU hardware this runs on in practice (CUDA/Metal/Vulkan-centric crates like `whisper-rs` have no strong story for e.g. Intel Arc or other OpenVINO-friendly hardware). + +### Deliberately not on this list + +A general indexer-plugin system (Prowlarr-style) and a web UI were both explicit, considered rejections early in this project, not gaps — a hardcoded, well-understood set of sources and a terminal-only client are the point, not a limitation to eventually fix. Nothing above should be read as walking either decision back; if a future need ever seriously challenges one of them, that deserves its own explicit reconsideration, not a quiet reversal buried in a roadmap item. + +## License + +MIT — see `LICENSE`. diff --git a/bakery.toml b/bakery.toml new file mode 100644 index 0000000..cad7a26 --- /dev/null +++ b/bakery.toml @@ -0,0 +1,21 @@ +name = "breadarr" +description = "Lightweight Sonarr/Radarr/Prowlarr replacement" +binaries = ["breadarrd", "breadarr-tui"] +# breadarr runs natively (not in Docker), so unlike the old Sonarr/Radarr/ +# Prowlarr containers it needs these on the host PATH directly. +system_deps = ["mkvtoolnix-cli", "ffmpeg"] +optional_system_deps = [] +bread_deps = [] + +[[service]] +unit = "breadarrd.service" +enable = true + +[config] +dir = "~/.config/breadarr" +example = "config.example.toml" + +[install] +post_install = [ + "systemctl --user is-active --quiet breadarrd || systemctl --user start breadarrd", +] diff --git a/breadarr-shared/Cargo.toml b/breadarr-shared/Cargo.toml new file mode 100644 index 0000000..0018ea6 --- /dev/null +++ b/breadarr-shared/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "breadarr-shared" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde.workspace = true +anyhow.workspace = true +toml.workspace = true +reqwest.workspace = true +chrono.workspace = true diff --git a/breadarr-shared/src/client.rs b/breadarr-shared/src/client.rs new file mode 100644 index 0000000..174caad --- /dev/null +++ b/breadarr-shared/src/client.rs @@ -0,0 +1,371 @@ +use anyhow::{Context, Result}; + +use crate::dto::{ + AddMovieRequest, AddMovieResponse, AddSeriesRequest, AddSeriesResponse, CalendarEntry, + GrabCandidateRequest, HealthDetail, LibraryHealthReport, MediaItemDetail, MediaItemSummary, + QualityProfileSummary, ReleaseCandidate, ReleaseSummary, ReviewQueueEntry, SearchNowResult, + SearchResult, StuckReport, UpdateQualityProfileWeightsRequest, WeightsDto, +}; + +pub struct DaemonClient { + base_url: String, + client: reqwest::Client, +} + +impl DaemonClient { + /// `api_token` mirrors `config.daemon.api_token` server-side — empty + /// means "no auth configured," so this stays a no-op default header + /// rather than sending a meaningless empty bearer token on every + /// request. + pub fn new(base_url: impl Into, api_token: &str) -> Self { + let mut builder = reqwest::Client::builder() + // A default so no request can hang the TUI forever with zero + // feedback if the daemon is unreachable or a connection stalls. + // Routes that legitimately need longer (search_now) or shorter + // (health) windows set their own per-request `.timeout(...)`, + // which overrides this. + .timeout(std::time::Duration::from_secs(30)); + if !api_token.is_empty() { + let mut headers = reqwest::header::HeaderMap::new(); + if let Ok(value) = + reqwest::header::HeaderValue::from_str(&format!("Bearer {api_token}")) + { + headers.insert(reqwest::header::AUTHORIZATION, value); + } + builder = builder.default_headers(headers); + } + Self { + base_url: base_url.into(), + client: builder + .build() + .expect("reqwest client builder should not fail with only a timeout/headers set"), + } + } + + pub async fn health(&self) -> Result { + let resp = self + .client + .get(format!("{}/health", self.base_url)) + .timeout(std::time::Duration::from_secs(2)) + .send() + .await; + Ok(matches!(resp, Ok(r) if r.status().is_success())) + } + + /// The richer health payload (per-cycle status, search_halted) — a + /// separate call from `health()` rather than folding this into it, so + /// a caller that only needs the fast up/down check (a tight poll loop) + /// isn't forced to also pay for parsing/holding the full detail. + pub async fn health_detail(&self) -> Result { + let resp = self + .client + .get(format!("{}/health", self.base_url)) + .timeout(std::time::Duration::from_secs(2)) + .send() + .await + .context("health_detail request failed")? + .error_for_status() + .context("health_detail returned an error status")?; + resp.json() + .await + .context("health_detail response was not valid JSON") + } + + pub async fn list_media(&self) -> Result> { + self.get("/media").await + } + + pub async fn media_detail(&self, id: i64) -> Result { + self.get(&format!("/media/{id}")).await + } + + pub async fn releases(&self) -> Result> { + self.get("/releases").await + } + + pub async fn review_queue(&self) -> Result> { + self.get("/review").await + } + + pub async fn stuck(&self) -> Result { + self.get("/stuck").await + } + + pub async fn calendar(&self) -> Result> { + self.get("/calendar").await + } + + pub async fn library_health(&self) -> Result { + self.get("/library/health").await + } + + pub async fn quality_profiles(&self) -> Result> { + self.get("/quality-profiles").await + } + + pub async fn update_quality_profile_weights(&self, id: i64, weights: WeightsDto) -> Result<()> { + let req = UpdateQualityProfileWeightsRequest { weights }; + self.client + .put(format!("{}/quality-profiles/{id}/weights", self.base_url)) + .json(&req) + .send() + .await + .context("update_quality_profile_weights request failed")? + .error_for_status() + .context("update_quality_profile_weights returned an error status")?; + Ok(()) + } + + pub async fn approve_review(&self, id: i64) -> Result<()> { + self.post_empty(&format!("/review/{id}/approve")).await + } + + pub async fn reject_review(&self, id: i64) -> Result<()> { + self.post_empty(&format!("/review/{id}/reject")).await + } + + pub async fn search_series(&self, query: &str) -> Result> { + let resp = self + .client + .get(format!("{}/search", self.base_url)) + .query(&[("q", query)]) + .send() + .await + .context("search request failed")? + .error_for_status() + .context("search returned an error status")?; + resp.json() + .await + .context("search response was not valid JSON") + } + + pub async fn add_series(&self, req: &AddSeriesRequest) -> Result { + let resp = self + .client + .post(format!("{}/media", self.base_url)) + .json(req) + .send() + .await + .context("add_series request failed")? + .error_for_status() + .context("add_series returned an error status")?; + resp.json() + .await + .context("add_series response was not valid JSON") + } + + pub async fn search_movies(&self, query: &str) -> Result> { + let resp = self + .client + .get(format!("{}/search", self.base_url)) + .query(&[("q", query), ("kind", "movie")]) + .send() + .await + .context("search request failed")? + .error_for_status() + .context("search returned an error status")?; + resp.json() + .await + .context("search response was not valid JSON") + } + + pub async fn add_movie(&self, req: &AddMovieRequest) -> Result { + let resp = self + .client + .post(format!("{}/media/movie", self.base_url)) + .json(req) + .send() + .await + .context("add_movie request failed")? + .error_for_status() + .context("add_movie returned an error status")?; + resp.json() + .await + .context("add_movie response was not valid JSON") + } + + /// Manual "search now" for one show/movie's whole current backlog — can + /// take a while (jittered, one request per missing item), so callers + /// should expect this to be slow, not instant. + pub async fn search_now(&self, id: i64) -> Result { + let resp = self + .client + .post(format!("{}/media/{id}/search", self.base_url)) + .timeout(std::time::Duration::from_secs(600)) + .send() + .await + .context("search_now request failed")? + .error_for_status() + .context("search_now returned an error status")?; + resp.json() + .await + .context("search_now response was not valid JSON") + } + + /// Scored (or gate-rejected) candidates for a movie, without grabbing — + /// the manual release picker. Same 600s allowance as `search_now` since + /// it runs a real search under the hood. + pub async fn movie_candidates(&self, media_item_id: i64) -> Result> { + let resp = self + .client + .get(format!( + "{}/media/{media_item_id}/candidates", + self.base_url + )) + .timeout(std::time::Duration::from_secs(600)) + .send() + .await + .context("movie_candidates request failed")? + .error_for_status() + .context("movie_candidates returned an error status")?; + resp.json() + .await + .context("movie_candidates response was not valid JSON") + } + + pub async fn episode_candidates(&self, episode_id: i64) -> Result> { + let resp = self + .client + .get(format!("{}/episode/{episode_id}/candidates", self.base_url)) + .timeout(std::time::Duration::from_secs(600)) + .send() + .await + .context("episode_candidates request failed")? + .error_for_status() + .context("episode_candidates returned an error status")?; + resp.json() + .await + .context("episode_candidates response was not valid JSON") + } + + pub async fn grab_movie_candidate( + &self, + media_item_id: i64, + candidate: &ReleaseCandidate, + ) -> Result<()> { + self.grab_candidate(&format!("/media/{media_item_id}/candidates"), candidate) + .await + } + + pub async fn grab_episode_candidate( + &self, + episode_id: i64, + candidate: &ReleaseCandidate, + ) -> Result<()> { + self.grab_candidate(&format!("/episode/{episode_id}/candidates"), candidate) + .await + } + + async fn grab_candidate(&self, path: &str, candidate: &ReleaseCandidate) -> Result<()> { + let req = GrabCandidateRequest { + raw_title: candidate.raw_title.clone(), + link: candidate.link.clone(), + guid: candidate.guid.clone(), + source_id: candidate.source_id, + }; + self.client + .post(format!("{}{path}", self.base_url)) + .timeout(std::time::Duration::from_secs(120)) + .json(&req) + .send() + .await + .with_context(|| format!("grab_candidate POST {path} failed"))? + .error_for_status() + .with_context(|| format!("grab_candidate POST {path} returned an error status"))?; + Ok(()) + } + + pub async fn monitor(&self, id: i64) -> Result<()> { + self.post_empty(&format!("/media/{id}/monitor")).await + } + + pub async fn unmonitor(&self, id: i64) -> Result<()> { + self.post_empty(&format!("/media/{id}/unmonitor")).await + } + + pub async fn monitor_episode(&self, episode_id: i64) -> Result<()> { + self.post_empty(&format!("/episode/{episode_id}/monitor")) + .await + } + + pub async fn unmonitor_episode(&self, episode_id: i64) -> Result<()> { + self.post_empty(&format!("/episode/{episode_id}/unmonitor")) + .await + } + + pub async fn monitor_season(&self, media_item_id: i64, season_number: i64) -> Result<()> { + self.post_empty(&format!( + "/media/{media_item_id}/season/{season_number}/monitor" + )) + .await + } + + pub async fn unmonitor_season(&self, media_item_id: i64, season_number: i64) -> Result<()> { + self.post_empty(&format!( + "/media/{media_item_id}/season/{season_number}/unmonitor" + )) + .await + } + + pub async fn delete_media(&self, id: i64) -> Result<()> { + self.client + .delete(format!("{}/media/{id}", self.base_url)) + .send() + .await + .context("delete_media request failed")? + .error_for_status() + .context("delete_media returned an error status")?; + Ok(()) + } + + /// Deletes an episode's imported file (from disk, not just tracking) so + /// it can be re-grabbed — for a confirmed-wrong or broken file, not a + /// routine action. + pub async fn delete_episode_file(&self, episode_id: i64) -> Result<()> { + self.client + .delete(format!("{}/episode/{episode_id}/file", self.base_url)) + .send() + .await + .context("delete_episode_file request failed")? + .error_for_status() + .context("delete_episode_file returned an error status")?; + Ok(()) + } + + /// Movie counterpart to `delete_episode_file`. + pub async fn delete_movie_file(&self, media_item_id: i64) -> Result<()> { + self.client + .delete(format!("{}/media/{media_item_id}/file", self.base_url)) + .send() + .await + .context("delete_movie_file request failed")? + .error_for_status() + .context("delete_movie_file returned an error status")?; + Ok(()) + } + + async fn get(&self, path: &str) -> Result { + let resp = self + .client + .get(format!("{}{path}", self.base_url)) + .send() + .await + .with_context(|| format!("GET {path} failed"))? + .error_for_status() + .with_context(|| format!("GET {path} returned an error status"))?; + resp.json() + .await + .with_context(|| format!("GET {path} response was not valid JSON")) + } + + async fn post_empty(&self, path: &str) -> Result<()> { + self.client + .post(format!("{}{path}", self.base_url)) + .send() + .await + .with_context(|| format!("POST {path} failed"))? + .error_for_status() + .with_context(|| format!("POST {path} returned an error status"))?; + Ok(()) + } +} diff --git a/breadarr-shared/src/config.rs b/breadarr-shared/src/config.rs new file mode 100644 index 0000000..64e47b6 --- /dev/null +++ b/breadarr-shared/src/config.rs @@ -0,0 +1,375 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use serde::Deserialize; + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Config { + #[serde(default)] + pub daemon: DaemonConfig, + #[serde(default)] + pub qbit: QbitConfig, + #[serde(default)] + pub jellyfin: JellyfinConfig, + #[serde(default)] + pub tvdb: TvdbConfig, + #[serde(default)] + pub tmdb: TmdbConfig, + #[serde(default)] + pub library: LibraryConfig, + #[serde(default)] + pub sources: SourcesConfig, + #[serde(default)] + pub notifications: NotificationsConfig, +} + +/// Where the TUI's "add show" flow places new series by default. Sonarr/ +/// Radarr let you pick a root folder per add; a single configured default +/// is a reasonable v1 simplification — per-add picking can follow later. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct LibraryConfig { + #[serde(default = "default_root_folder")] + pub default_root_folder: String, +} + +fn default_root_folder() -> String { + "~/breadarr-library".to_string() +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SourcesConfig { + /// 1337x's main domain bans IPs at the Cloudflare WAF level after + /// bursts of automated traffic. Its community mirrors run on separate + /// domains/Cloudflare zones, so a ban on one doesn't carry over — tried + /// in order, falling back on failure, so losing one to a future ban + /// doesn't take the source down. Verified working (not Cloudflare- + /// challenged, genuine 1337x content) as of 2026-07-11. + #[serde(default = "default_1337x_mirrors")] + pub torrent_1337x_mirrors: Vec, + /// English-translated anime category — matches the actual anime library + /// (nyaa carries no non-anime content, so a broader/unfiltered feed + /// would just be pure noise against everything else monitored). + #[serde(default = "default_nyaa_rss_url")] + pub nyaa_rss_url: String, + #[serde(default = "default_grab_poll_interval_secs")] + pub grab_poll_interval_secs: u64, + #[serde(default = "default_import_poll_interval_secs")] + pub import_poll_interval_secs: u64, + #[serde(default = "default_search_poll_interval_secs")] + pub search_poll_interval_secs: u64, + /// Search requests per cycle across 1337x + nyaa search combined — kept + /// small since this is the one source with a ban history; clears a + /// 50-item backlog in ~5 hours at the default interval without ever + /// looking like a request flood to any single mirror. + #[serde(default = "default_search_budget_per_cycle")] + pub search_budget_per_cycle: usize, + /// Human kill switch for the search-driven loop — a restart resets all + /// in-memory mirror cooldown/backoff state, so this (not a persisted + /// flag) is the deliberate way to keep it off across restarts. + #[serde(default = "default_search_enabled")] + pub search_enabled: bool, + /// A community JSON API mirror of The Pirate Bay's search — the + /// primary general-content (movies + non-anime TV) search source. + /// Unlike 1337x this needs no HTML scraping and actually ranks by + /// relevance rather than pure seeder count, which matters a lot for + /// titles made of common words. + #[serde(default = "default_tpb_api_url")] + pub tpb_api_url: String, + /// Human kill switch for the upgrade-search loop, same reasoning as + /// `search_enabled` — off by default would mean nothing ever improves, + /// but a user who's happy with their current files (or wants to save + /// request budget) can disable it independently of missing-content + /// search. + #[serde(default = "default_upgrade_enabled")] + pub upgrade_enabled: bool, + /// Deliberately much longer than `search_poll_interval_secs` — this + /// loop re-checks content that's already satisfied (a file exists), so + /// there's no urgency the way a missing episode has, and every cycle + /// still costs the same request budget as a missing-content search. + #[serde(default = "default_upgrade_poll_interval_secs")] + pub upgrade_poll_interval_secs: u64, + #[serde(default = "default_upgrade_budget_per_cycle")] + pub upgrade_budget_per_cycle: usize, + /// Minimum score improvement (on top of the same weighted-score scale + /// `should_grab` already compares) required before a periodic upgrade + /// check will actually re-grab — without this, a file already on disk + /// could get replaced over and over for score deltas too small to + /// matter, wasting bandwidth on churn. Repacks/propers always supersede + /// regardless of this threshold, same as the normal grab path. + #[serde(default = "default_upgrade_min_score_gain")] + pub upgrade_min_score_gain: f32, +} + +impl Default for SourcesConfig { + fn default() -> Self { + Self { + torrent_1337x_mirrors: default_1337x_mirrors(), + nyaa_rss_url: default_nyaa_rss_url(), + grab_poll_interval_secs: default_grab_poll_interval_secs(), + import_poll_interval_secs: default_import_poll_interval_secs(), + search_poll_interval_secs: default_search_poll_interval_secs(), + search_budget_per_cycle: default_search_budget_per_cycle(), + search_enabled: default_search_enabled(), + tpb_api_url: default_tpb_api_url(), + upgrade_enabled: default_upgrade_enabled(), + upgrade_poll_interval_secs: default_upgrade_poll_interval_secs(), + upgrade_budget_per_cycle: default_upgrade_budget_per_cycle(), + upgrade_min_score_gain: default_upgrade_min_score_gain(), + } + } +} + +fn default_tpb_api_url() -> String { + "https://apibay.org/q.php".to_string() +} + +fn default_upgrade_enabled() -> bool { + true +} + +fn default_upgrade_poll_interval_secs() -> u64 { + 6 * 60 * 60 +} + +fn default_upgrade_budget_per_cycle() -> usize { + 3 +} + +fn default_upgrade_min_score_gain() -> f32 { + 5.0 +} + +fn default_search_poll_interval_secs() -> u64 { + 30 * 60 +} + +fn default_search_budget_per_cycle() -> usize { + 5 +} + +fn default_search_enabled() -> bool { + true +} + +fn default_nyaa_rss_url() -> String { + "https://nyaa.si/?page=rss&c=1_2".to_string() +} + +fn default_grab_poll_interval_secs() -> u64 { + 300 +} + +fn default_import_poll_interval_secs() -> u64 { + 60 +} + +fn default_1337x_mirrors() -> Vec { + [ + "https://13377x.info", + "https://13377x.email", + "https://1337xto.info", + "https://1337x.maskbay.info", + "https://1337x.ninjaproxy.live", + "https://1337x.proxyhive.pro", + "https://1337x.torproxy.live", + "https://1337x.unblockit.world", + "https://1337x.unblockpirate.xyz", + "https://1337x.unblockshark.info", + "https://1337x.unblocktorrent.click", + "https://1337x.unblocktorrent.info", + "https://1337x.unblocktor.xyz", + ] + .into_iter() + .map(String::from) + .collect() +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DaemonConfig { + #[serde(default = "default_log_level")] + pub log_level: String, + #[serde(default = "default_listen_addr")] + pub listen_addr: String, + #[serde(default = "default_db_path")] + pub db_path: String, + #[serde(default = "default_model_dir")] + pub model_dir: String, + /// Bearer token required on every API request when non-empty. Empty + /// (the default) means auth is off entirely — `listen_addr` defaults to + /// loopback-only, so a fresh install isn't suddenly locked out of its + /// own unconfigured daemon. This matters once `listen_addr` is changed + /// to bind non-loopback (e.g. so a TUI on a different host on the same + /// tailnet can reach it) — without a token, that's unauthenticated + /// add/delete/search access to anyone who can reach the port. + #[serde(default)] + pub api_token: String, +} + +impl Default for DaemonConfig { + fn default() -> Self { + Self { + log_level: default_log_level(), + listen_addr: default_listen_addr(), + db_path: default_db_path(), + model_dir: default_model_dir(), + api_token: String::new(), + } + } +} + +/// qBittorrent WebUI connection. `base_url` empty means "not configured" — +/// callers should error out rather than guessing a default. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct QbitConfig { + #[serde(default)] + pub base_url: String, + #[serde(default)] + pub username: String, + #[serde(default)] + pub password: String, + #[serde(default = "default_qbit_category")] + pub category: String, + /// qBittorrent's own container-internal path prefix for its downloads + /// (e.g. "/downloads"), when it runs in Docker while breadarr runs + /// natively on the same host — needed to translate the paths qBittorrent + /// reports via its API into paths breadarr can actually open. Both empty + /// means no remapping (qBittorrent's reported paths are used as-is). + #[serde(default)] + pub container_downloads_path: String, + #[serde(default)] + pub host_downloads_path: String, +} + +/// Push-notification target for events that otherwise sit invisible until +/// the TUI is next opened (a review-queue item, a run of import failures, +/// the search-driven loop halting). `webhook_url` empty means "not +/// configured" — no notifications sent, matching every other optional +/// integration's default. The payload is a simple `{"title", "message"}` +/// JSON body, which is directly Gotify's own message API shape (this +/// user's actual self-hosted push service) and close enough to what most +/// other self-hosted webhook receivers expect that this isn't tied to one +/// specific service. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct NotificationsConfig { + #[serde(default)] + pub webhook_url: String, +} + +/// Jellyfin API connection. `base_url`/`api_key` empty means "not configured". +#[derive(Debug, Clone, Default, Deserialize)] +pub struct JellyfinConfig { + #[serde(default)] + pub base_url: String, + #[serde(default)] + pub api_key: String, +} + +/// TVDB v4 API key, exchanged for a short-lived JWT at request time. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct TvdbConfig { + #[serde(default)] + pub api_key: String, +} + +/// TMDB API Read Access Token (v4 auth), used directly as a bearer token. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct TmdbConfig { + #[serde(default)] + pub bearer_token: String, +} + +impl Config { + pub fn load() -> Result { + let path = config_path(); + if !path.exists() { + return Ok(Self::default()); + } + + let raw = fs::read_to_string(&path)?; + let cfg: Config = toml::from_str(&raw)?; + Ok(cfg) + } + + pub fn db_path(&self) -> PathBuf { + expand_home(&self.daemon.db_path) + } + + pub fn model_dir(&self) -> PathBuf { + expand_home(&self.daemon.model_dir) + } + + pub fn default_root_folder(&self) -> PathBuf { + expand_home(&self.library.default_root_folder) + } +} + +fn config_path() -> PathBuf { + if let Ok(xdg) = env::var("XDG_CONFIG_HOME") { + return Path::new(&xdg).join("breadarr").join("breadarrd.toml"); + } + + expand_home("~/.config/breadarr/breadarrd.toml") +} + +fn expand_home(input: &str) -> PathBuf { + if let Some(stripped) = input.strip_prefix("~/") { + if let Ok(home) = env::var("HOME") { + return Path::new(&home).join(stripped); + } + } + PathBuf::from(input) +} + +fn default_log_level() -> String { + "info".to_string() +} + +fn default_listen_addr() -> String { + "127.0.0.1:7879".to_string() +} + +fn default_db_path() -> String { + "~/.local/share/breadarr/breadarr.db".to_string() +} + +fn default_model_dir() -> String { + "~/.cache/breadarr/models/all-MiniLM-L6-v2".to_string() +} + +fn default_qbit_category() -> String { + "breadarr".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_has_expected_values() { + let cfg = Config::default(); + assert_eq!(cfg.daemon.log_level, "info"); + assert_eq!(cfg.daemon.listen_addr, "127.0.0.1:7879"); + } + + #[test] + fn load_falls_back_to_default_when_file_missing() { + // SAFETY: single-threaded test setting an isolated var it also restores below. + unsafe { + env::set_var("XDG_CONFIG_HOME", "/tmp/breadarr-test-nonexistent-dir"); + } + let cfg = Config::load().unwrap(); + assert_eq!(cfg.daemon.log_level, "info"); + unsafe { + env::remove_var("XDG_CONFIG_HOME"); + } + } + + #[test] + fn parses_partial_toml_with_defaults() { + let cfg: Config = toml::from_str("[daemon]\nlog_level = \"debug\"\n").unwrap(); + assert_eq!(cfg.daemon.log_level, "debug"); + assert_eq!(cfg.daemon.listen_addr, "127.0.0.1:7879"); + } +} diff --git a/breadarr-shared/src/dto.rs b/breadarr-shared/src/dto.rs new file mode 100644 index 0000000..72e330b --- /dev/null +++ b/breadarr-shared/src/dto.rs @@ -0,0 +1,265 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MediaItemSummary { + pub id: i64, + pub kind: String, + pub title: String, + pub year: Option, + pub monitored: bool, + pub episode_count: i64, + pub missing_count: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EpisodeSummary { + pub id: i64, + pub season_number: i64, + pub episode_number: i64, + pub title: Option, + pub air_date: Option, + pub monitored: bool, + pub has_file: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MediaItemDetail { + pub id: i64, + pub kind: String, + pub title: String, + pub year: Option, + pub monitored: bool, + pub root_folder: String, + pub episodes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReleaseSummary { + pub id: i64, + pub media_title: String, + pub raw_title: String, + pub score: Option, + pub status: String, + pub grabbed_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReviewQueueEntry { + pub id: i64, + pub raw_release_title: String, + pub candidate_media_title: Option, + pub confidence: f64, + pub status: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StalledGrab { + pub release_id: i64, + pub media_title: String, + pub raw_title: String, + pub grabbed_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MaxedSearchTarget { + pub media_item_id: i64, + pub media_title: String, + pub search_count: i64, + pub last_searched_at: Option, +} + +/// The daily "why don't I have this yet" answer — grabs that have been +/// sitting without importing longer than expected, how deep the review +/// queue has backed up, and search targets that have been failing every +/// attempt for so long their backoff has hit its ceiling. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StuckReport { + pub stalled_grabs: Vec, + pub review_queue_depth: i64, + pub maxed_out_search_targets: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchResult { + pub external_id: String, + pub title: String, + pub year: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddSeriesRequest { + pub tvdb_id: String, + pub title: String, + pub year: Option, + pub aliases: Vec, + pub root_folder: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddSeriesResponse { + pub media_item_id: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddMovieRequest { + pub tmdb_id: String, + pub title: String, + pub year: Option, + pub root_folder: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddMovieResponse { + pub media_item_id: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchNowResult { + pub targets: usize, + pub searched: usize, + pub grabbed: usize, + pub errors: usize, + pub source_exhausted: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CalendarEntry { + pub media_item_id: i64, + pub media_title: String, + pub season_number: i64, + pub episode_number: i64, + pub title: Option, + pub air_date: String, + pub monitored: bool, + pub has_file: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthDetail { + pub status: String, + pub last_grab_cycle: Option, + pub last_import_cycle: Option, + pub last_search_cycle: Option, + pub last_upgrade_cycle: Option, + pub search_halted: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CycleInfo { + pub at: chrono::DateTime, + pub ok: bool, + pub detail: String, +} + +/// One file flagged by a `media_file_probe` category — reused across every +/// flag list in `LibraryHealthReport` so a client can render them all with +/// the same widget. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlaggedFile { + pub episode_file_id: i64, + pub media_title: String, + pub episode_label: Option, + pub path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DuplicateGroup { + pub media_title: String, + pub episode_label: Option, + pub paths: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodecCount { + pub codec: String, + pub count: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LibrarySummary { + pub total_files: i64, + pub total_size_bytes: i64, + pub probed_files: i64, + pub by_video_codec: Vec, + pub sd_count: i64, + pub hd_720p_count: i64, + pub full_hd_1080p_count: i64, + pub uhd_4k_count: i64, + pub pct_with_subtitles: f64, +} + +/// One search result scored (or gate-rejected) for manual review — the +/// same candidate a search cycle would evaluate automatically, surfaced +/// before a grab decision is made instead of after. `link`/`guid`/ +/// `source_id` are carried through so a chosen candidate can be grabbed +/// directly without re-searching. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReleaseCandidate { + pub raw_title: String, + pub link: String, + pub guid: String, + pub source_id: i64, + pub source_name: String, + pub seeders: Option, + pub leechers: Option, + pub size_bytes: Option, + /// `None` when gate-rejected — `rejected_reason` explains why. + pub score: Option, + pub rejected_reason: Option, + pub resolution: Option, + pub is_repack: bool, + pub is_season_pack: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GrabCandidateRequest { + pub raw_title: String, + pub link: String, + pub guid: String, + pub source_id: i64, +} + +/// Every scoring axis `QualityProfile::Weights` has, mirrored 1:1 for the +/// wire — used both to show the currently-effective (defaults-plus-stored- +/// override) values and to submit a full replacement set when the user +/// edits one. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct WeightsDto { + pub seeder: f32, + pub resolution_tier: f32, + pub source_tier: f32, + pub codec_tier: f32, + pub bit_depth: f32, + pub container: f32, + pub group_allowlist: f32, + pub repack: f32, + pub hdr: f32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QualityProfileSummary { + pub id: i64, + pub name: String, + pub kind: String, + /// Always the fully-resolved values (built-in defaults with any stored + /// override already applied) — never a partial/sparse object, so the + /// TUI always has something concrete to display and re-submit. + pub weights: WeightsDto, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateQualityProfileWeightsRequest { + pub weights: WeightsDto, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LibraryHealthReport { + pub corrupt_files: Vec, + pub under_quality_files: Vec, + pub no_subtitle_files: Vec, + pub no_english_audio_files: Vec, + pub non_english_default_audio_files: Vec, + pub duplicate_groups: Vec, + pub summary: LibrarySummary, +} diff --git a/breadarr-shared/src/lib.rs b/breadarr-shared/src/lib.rs new file mode 100644 index 0000000..0b07890 --- /dev/null +++ b/breadarr-shared/src/lib.rs @@ -0,0 +1,6 @@ +pub mod client; +pub mod config; +pub mod dto; + +pub use client::DaemonClient; +pub use config::Config; diff --git a/breadarr-tui/Cargo.toml b/breadarr-tui/Cargo.toml new file mode 100644 index 0000000..90bcac8 --- /dev/null +++ b/breadarr-tui/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "breadarr-tui" +version = "0.1.0" +edition = "2021" + +[dependencies] +breadarr-shared.workspace = true +tokio.workspace = true +anyhow.workspace = true +reqwest.workspace = true +serde.workspace = true +ratatui.workspace = true +crossterm.workspace = true +chrono.workspace = true diff --git a/breadarr-tui/src/app.rs b/breadarr-tui/src/app.rs new file mode 100644 index 0000000..f3b1ac5 --- /dev/null +++ b/breadarr-tui/src/app.rs @@ -0,0 +1,792 @@ +use anyhow::Result; +use breadarr_shared::dto::{ + CalendarEntry, HealthDetail, LibraryHealthReport, MediaItemDetail, MediaItemSummary, + QualityProfileSummary, ReleaseCandidate, ReleaseSummary, ReviewQueueEntry, SearchResult, + StuckReport, WeightsDto, +}; +use breadarr_shared::DaemonClient; +use ratatui::widgets::ListState; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tab { + Library, + History, + Review, + Add, + Stuck, + Calendar, + LibraryHealth, + Profiles, +} + +impl Tab { + pub const ALL: [Tab; 8] = [ + Tab::Library, + Tab::History, + Tab::Review, + Tab::Add, + Tab::Stuck, + Tab::Calendar, + Tab::LibraryHealth, + Tab::Profiles, + ]; + + pub fn title(&self) -> &'static str { + match self { + Tab::Library => "Library", + Tab::History => "History", + Tab::Review => "Review Queue", + Tab::Add => "Add Show", + Tab::Stuck => "Stuck", + Tab::Calendar => "Calendar", + Tab::LibraryHealth => "Health", + Tab::Profiles => "Profiles", + } + } +} + +pub enum Focus { + List, + AddSearchInput, + AddResults, + /// The manual release picker overlay — `App::candidates` holds the + /// list, `App::candidates_episode_id` remembers which episode (or, if + /// `None`, the open movie) it was fetched for so a grab can be + /// submitted against the right target. + Candidates, + /// Text-entry mode for one weight axis on the Profiles tab's detail + /// view — `App::weight_input_buffer` holds the in-progress digits. + WeightInput, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AddKind { + Series, + Movie, +} + +impl AddKind { + pub fn label(&self) -> &'static str { + match self { + AddKind::Series => "TV", + AddKind::Movie => "Movie", + } + } +} + +/// A weight axis's display name plus a getter/setter pair, so +/// `WEIGHT_FIELDS` can enumerate `WeightsDto`'s fields by position instead +/// of every caller matching on an index. +type WeightField = ( + &'static str, + fn(&WeightsDto) -> f32, + fn(&mut WeightsDto, f32), +); + +/// One row per `WeightsDto` axis, in the fixed order shown/edited on the +/// Profiles tab. +pub const WEIGHT_FIELDS: [WeightField; 9] = [ + ("seeder", |w| w.seeder, |w, v| w.seeder = v), + ( + "resolution_tier", + |w| w.resolution_tier, + |w, v| w.resolution_tier = v, + ), + ("source_tier", |w| w.source_tier, |w, v| w.source_tier = v), + ("codec_tier", |w| w.codec_tier, |w, v| w.codec_tier = v), + ("bit_depth", |w| w.bit_depth, |w, v| w.bit_depth = v), + ("container", |w| w.container, |w, v| w.container = v), + ( + "group_allowlist", + |w| w.group_allowlist, + |w, v| w.group_allowlist = v, + ), + ("repack", |w| w.repack, |w, v| w.repack = v), + ("hdr", |w| w.hdr, |w, v| w.hdr = v), +]; + +/// A search hit tagged with which endpoint it came from — `run_add_search` +/// queries series and movies together (see its doc comment for why), so +/// each result needs to remember its own kind rather than the app tracking +/// one global mode. +#[derive(Debug, Clone)] +pub struct AddResult { + pub kind: AddKind, + pub result: SearchResult, +} + +pub struct App { + pub client: DaemonClient, + pub daemon_up: bool, + pub health: Option, + pub tab: Tab, + pub focus: Focus, + pub status: String, + pub should_quit: bool, + + pub media_items: Vec, + pub media_state: ListState, + pub detail: Option, + /// Selection within `detail.episodes` — separate from `media_state` + /// since they're two different lists sharing the same tab. + pub episode_state: ListState, + + pub releases: Vec, + pub releases_state: ListState, + + pub review_items: Vec, + pub review_state: ListState, + + pub add_query: String, + pub add_results: Vec, + pub add_results_state: ListState, + + pub stuck: Option, + pub calendar: Vec, + pub library_health: Option, + + pub candidates: Vec, + pub candidates_state: ListState, + /// Which episode the current `candidates` list was fetched for — + /// `None` means it was fetched for the open movie itself. + pub candidates_episode_id: Option, + + /// Set by a first `x` press in the Library detail view; a second press + /// while this is `true` actually deletes. Any other key clears it. A + /// lightweight guard against an accidental single keystroke deleting a + /// tracked show/movie. + pub confirm_delete: bool, + + /// Same two-press guard as `confirm_delete`, but for deleting an + /// imported *file* (not the whole tracked show/movie) — a separate + /// flag since the two actions have different confirmation text and + /// shouldn't arm each other. + pub confirm_delete_file: bool, + + pub quality_profiles: Vec, + pub profiles_state: ListState, + /// The profile currently open for editing — separate from + /// `quality_profiles` (like `detail` vs. `media_items`) so the two + /// list levels (profiles, then that profile's weight axes) don't + /// share a selection. + pub profile_detail: Option, + pub profile_weight_state: ListState, + /// In-progress digits while `Focus::WeightInput` is active. + pub weight_input_buffer: String, +} + +impl App { + pub fn new(client: DaemonClient) -> Self { + Self { + client, + daemon_up: false, + health: None, + tab: Tab::Library, + focus: Focus::List, + status: String::new(), + should_quit: false, + media_items: Vec::new(), + media_state: ListState::default(), + detail: None, + episode_state: ListState::default(), + releases: Vec::new(), + releases_state: ListState::default(), + review_items: Vec::new(), + review_state: ListState::default(), + add_query: String::new(), + add_results: Vec::new(), + add_results_state: ListState::default(), + stuck: None, + calendar: Vec::new(), + library_health: None, + candidates: Vec::new(), + candidates_state: ListState::default(), + candidates_episode_id: None, + confirm_delete: false, + confirm_delete_file: false, + quality_profiles: Vec::new(), + profiles_state: ListState::default(), + profile_detail: None, + profile_weight_state: ListState::default(), + weight_input_buffer: String::new(), + } + } + + pub async fn refresh_active_tab(&mut self) { + match self.client.health_detail().await { + Ok(detail) => { + self.daemon_up = true; + self.health = Some(detail); + } + Err(_) => { + self.daemon_up = false; + self.health = None; + } + } + if !self.daemon_up { + self.status = "daemon unreachable".to_string(); + return; + } + let result: Result<()> = async { + match self.tab { + Tab::Library => { + if self.detail.is_some() { + if let Some(id) = self.selected_media_id() { + self.detail = Some(self.client.media_detail(id).await?); + } + } else { + self.media_items = self.client.list_media().await?; + if self.media_state.selected().is_none() && !self.media_items.is_empty() { + self.media_state.select(Some(0)); + } + } + } + Tab::History => { + self.releases = self.client.releases().await?; + if self.releases_state.selected().is_none() && !self.releases.is_empty() { + self.releases_state.select(Some(0)); + } + } + Tab::Review => { + self.review_items = self.client.review_queue().await?; + if self.review_state.selected().is_none() && !self.review_items.is_empty() { + self.review_state.select(Some(0)); + } + } + Tab::Add => {} + Tab::Stuck => { + self.stuck = Some(self.client.stuck().await?); + } + Tab::Calendar => { + self.calendar = self.client.calendar().await?; + } + Tab::LibraryHealth => { + self.library_health = Some(self.client.library_health().await?); + } + Tab::Profiles => { + if self.profile_detail.is_none() { + self.quality_profiles = self.client.quality_profiles().await?; + if self.profiles_state.selected().is_none() + && !self.quality_profiles.is_empty() + { + self.profiles_state.select(Some(0)); + } + } + } + } + Ok(()) + } + .await; + + if let Err(e) = result { + self.status = format!("error: {e}"); + } + } + + fn selected_media_id(&self) -> Option { + self.detail.as_ref().map(|d| d.id) + } + + pub fn move_selection(&mut self, delta: i32) { + let (state, len) = match self.tab { + Tab::Library if matches!(self.focus, Focus::Candidates) => { + (&mut self.candidates_state, self.candidates.len()) + } + Tab::Library if self.detail.is_none() => { + (&mut self.media_state, self.media_items.len()) + } + Tab::Library => ( + &mut self.episode_state, + self.detail.as_ref().map_or(0, |d| d.episodes.len()), + ), + Tab::History => (&mut self.releases_state, self.releases.len()), + Tab::Review => (&mut self.review_state, self.review_items.len()), + Tab::Add => (&mut self.add_results_state, self.add_results.len()), + Tab::Profiles if self.profile_detail.is_some() => { + (&mut self.profile_weight_state, WEIGHT_FIELDS.len()) + } + Tab::Profiles => (&mut self.profiles_state, self.quality_profiles.len()), + _ => return, + }; + if len == 0 { + return; + } + let current = state.selected().unwrap_or(0) as i32; + let next = (current + delta).clamp(0, len as i32 - 1); + state.select(Some(next as usize)); + } + + pub async fn open_detail(&mut self) { + if !matches!(self.tab, Tab::Library) || self.detail.is_some() { + return; + } + let Some(idx) = self.media_state.selected() else { + return; + }; + let Some(item) = self.media_items.get(idx) else { + return; + }; + match self.client.media_detail(item.id).await { + Ok(detail) => { + self.episode_state.select(if detail.episodes.is_empty() { + None + } else { + Some(0) + }); + self.detail = Some(detail); + } + Err(e) => self.status = format!("error loading detail: {e}"), + } + } + + pub fn close_detail(&mut self) { + self.detail = None; + self.episode_state.select(None); + } + + /// Toggles monitored on whichever episode is currently selected in the + /// open detail view. + pub async fn toggle_monitor_selected_episode(&mut self) { + let Some(detail) = &self.detail else { + return; + }; + let Some(idx) = self.episode_state.selected() else { + return; + }; + let Some(episode) = detail.episodes.get(idx) else { + return; + }; + let episode_id = episode.id; + let result = if episode.monitored { + self.client.unmonitor_episode(episode_id).await + } else { + self.client.monitor_episode(episode_id).await + }; + match result { + Ok(()) => { + self.status = "episode monitor state updated".to_string(); + if let Some(id) = self.selected_media_id() { + if let Ok(fresh) = self.client.media_detail(id).await { + self.detail = Some(fresh); + } + } + } + Err(e) => self.status = format!("episode monitor toggle failed: {e}"), + } + } + + /// Toggles monitored on every episode in the currently-selected + /// episode's season at once. + pub async fn toggle_monitor_selected_season(&mut self) { + let Some(detail) = &self.detail else { + return; + }; + let Some(idx) = self.episode_state.selected() else { + return; + }; + let Some(episode) = detail.episodes.get(idx) else { + return; + }; + let (media_item_id, season_number, monitored) = + (detail.id, episode.season_number as i64, episode.monitored); + let result = if monitored { + self.client + .unmonitor_season(media_item_id, season_number) + .await + } else { + self.client + .monitor_season(media_item_id, season_number) + .await + }; + match result { + Ok(()) => { + self.status = format!("season {season_number} monitor state updated"); + if let Ok(fresh) = self.client.media_detail(media_item_id).await { + self.detail = Some(fresh); + } + } + Err(e) => self.status = format!("season monitor toggle failed: {e}"), + } + } + + pub async fn approve_selected_review(&mut self) { + let Some(idx) = self.review_state.selected() else { + return; + }; + let Some(item) = self.review_items.get(idx).cloned() else { + return; + }; + match self.client.approve_review(item.id).await { + Ok(()) => self.status = format!("approved: {}", item.raw_release_title), + Err(e) => self.status = format!("approve failed: {e}"), + } + self.review_items = self.client.review_queue().await.unwrap_or_default(); + } + + pub async fn reject_selected_review(&mut self) { + let Some(idx) = self.review_state.selected() else { + return; + }; + let Some(item) = self.review_items.get(idx).cloned() else { + return; + }; + match self.client.reject_review(item.id).await { + Ok(()) => self.status = format!("rejected: {}", item.raw_release_title), + Err(e) => self.status = format!("reject failed: {e}"), + } + self.review_items = self.client.review_queue().await.unwrap_or_default(); + } + + /// Searches series and movies together instead of requiring the user to + /// pick a mode first — a prior single-mode-plus-toggle design left the + /// toggle bound to F2, which some terminals/window managers swallow + /// before it ever reaches the TUI, silently stranding the search in the + /// wrong mode with no visible error (a real, confusing dead end hit live: + /// a movie search returned nothing because the mode had never actually + /// switched). Querying both up front removes the failure mode entirely. + pub async fn run_add_search(&mut self) { + if self.add_query.trim().is_empty() { + return; + } + let (series_result, movie_result) = tokio::join!( + self.client.search_series(&self.add_query), + self.client.search_movies(&self.add_query) + ); + + let mut results = Vec::new(); + let mut errors = Vec::new(); + match series_result { + Ok(hits) => results.extend(hits.into_iter().map(|result| AddResult { + kind: AddKind::Series, + result, + })), + Err(e) => errors.push(format!("series search failed: {e}")), + } + match movie_result { + Ok(hits) => results.extend(hits.into_iter().map(|result| AddResult { + kind: AddKind::Movie, + result, + })), + Err(e) => errors.push(format!("movie search failed: {e}")), + } + results.sort_by_key(|a| a.result.title.to_lowercase()); + + self.add_results_state + .select(if results.is_empty() { None } else { Some(0) }); + self.add_results = results; + self.status = if errors.is_empty() { + String::new() + } else { + errors.join("; ") + }; + self.focus = Focus::AddResults; + } + + pub async fn add_selected_search_result(&mut self, root_folder: &str) { + let Some(idx) = self.add_results_state.selected() else { + return; + }; + let Some(AddResult { kind, result }) = self.add_results.get(idx).cloned() else { + return; + }; + let outcome = match kind { + AddKind::Series => { + let req = breadarr_shared::dto::AddSeriesRequest { + tvdb_id: result.external_id.clone(), + title: result.title.clone(), + year: result.year, + aliases: Vec::new(), + root_folder: root_folder.to_string(), + }; + self.client.add_series(&req).await.map(|r| r.media_item_id) + } + AddKind::Movie => { + let req = breadarr_shared::dto::AddMovieRequest { + tmdb_id: result.external_id.clone(), + title: result.title.clone(), + year: result.year, + root_folder: root_folder.to_string(), + }; + self.client.add_movie(&req).await.map(|r| r.media_item_id) + } + }; + match outcome { + Ok(media_item_id) => { + self.status = format!("added {:?} (media_item_id={media_item_id})", result.title); + self.add_results.clear(); + self.add_query.clear(); + self.focus = Focus::AddSearchInput; + } + Err(e) => self.status = format!("add failed: {e}"), + } + } + + /// Manual "search now" for the show/movie currently open in the Library + /// detail view — can take a while (jittered, one request per missing + /// item), so the status line makes that explicit rather than looking + /// like the UI hung. + pub async fn search_now_selected(&mut self) { + let Some(id) = self.detail.as_ref().map(|d| d.id) else { + return; + }; + self.status = "searching now (this can take a while)...".to_string(); + match self.client.search_now(id).await { + Ok(stats) => { + self.status = format!( + "search complete: {} target(s), {} grabbed, {} error(s)", + stats.targets, stats.grabbed, stats.errors + ); + } + Err(e) => self.status = format!("search failed: {e}"), + } + } + + /// Fetches candidates for whatever's selected in the open detail view — + /// the currently-highlighted episode, or the movie itself if there's no + /// episode list. Can take a while, same as `search_now_selected`, since + /// it runs a real search under the hood. + pub async fn fetch_candidates_for_selected(&mut self) { + let Some(detail) = &self.detail else { + return; + }; + let episode_id = if detail.kind == "movie" { + None + } else { + let Some(idx) = self.episode_state.selected() else { + return; + }; + let Some(episode) = detail.episodes.get(idx) else { + return; + }; + Some(episode.id) + }; + let media_item_id = detail.id; + + self.status = "fetching candidates (this can take a while)...".to_string(); + let result = match episode_id { + Some(id) => self.client.episode_candidates(id).await, + None => self.client.movie_candidates(media_item_id).await, + }; + match result { + Ok(candidates) => { + self.status = format!("{} candidate(s) found", candidates.len()); + self.candidates_state + .select(if candidates.is_empty() { None } else { Some(0) }); + self.candidates = candidates; + self.candidates_episode_id = episode_id; + self.focus = Focus::Candidates; + } + Err(e) => self.status = format!("candidate fetch failed: {e}"), + } + } + + /// Grabs whichever candidate is currently selected in the picker + /// overlay, then returns to the normal detail view and refreshes it. + pub async fn grab_selected_candidate(&mut self) { + let Some(idx) = self.candidates_state.selected() else { + return; + }; + let Some(candidate) = self.candidates.get(idx).cloned() else { + return; + }; + let Some(media_item_id) = self.detail.as_ref().map(|d| d.id) else { + return; + }; + let result = match self.candidates_episode_id { + Some(episode_id) => { + self.client + .grab_episode_candidate(episode_id, &candidate) + .await + } + None => { + self.client + .grab_movie_candidate(media_item_id, &candidate) + .await + } + }; + match result { + Ok(()) => { + self.status = format!("grabbed: {}", candidate.raw_title); + self.close_candidates(); + if let Ok(fresh) = self.client.media_detail(media_item_id).await { + self.detail = Some(fresh); + } + } + Err(e) => self.status = format!("grab failed: {e}"), + } + } + + pub fn close_candidates(&mut self) { + self.candidates.clear(); + self.candidates_state.select(None); + self.candidates_episode_id = None; + self.focus = Focus::List; + } + + pub fn open_profile_detail(&mut self) { + if !matches!(self.tab, Tab::Profiles) || self.profile_detail.is_some() { + return; + } + let Some(idx) = self.profiles_state.selected() else { + return; + }; + let Some(profile) = self.quality_profiles.get(idx).cloned() else { + return; + }; + self.profile_detail = Some(profile); + self.profile_weight_state.select(Some(0)); + } + + pub fn close_profile_detail(&mut self) { + self.profile_detail = None; + self.profile_weight_state.select(None); + self.weight_input_buffer.clear(); + self.focus = Focus::List; + } + + /// Seeds the edit buffer from the currently-selected weight axis's + /// value and switches focus into text-entry mode — mirrors the Add + /// tab's search-input pattern (`Focus::AddSearchInput`), just for a + /// numeric field instead of a search query. + pub fn start_editing_selected_weight(&mut self) { + let Some(profile) = &self.profile_detail else { + return; + }; + let Some(idx) = self.profile_weight_state.selected() else { + return; + }; + let Some((_, get, _)) = WEIGHT_FIELDS.get(idx) else { + return; + }; + self.weight_input_buffer = format!("{}", get(&profile.weights)); + self.focus = Focus::WeightInput; + } + + pub fn cancel_weight_edit(&mut self) { + self.weight_input_buffer.clear(); + self.focus = Focus::List; + } + + /// Parses the edit buffer, applies it to the in-memory profile, and + /// submits the *complete* resolved weights set to the daemon — the API + /// always overwrites the full stored JSON (see `update_weights`'s doc + /// comment server-side), so every other axis has to be sent along + /// unchanged, not just the one being edited. + pub async fn commit_weight_edit(&mut self) { + let Some(idx) = self.profile_weight_state.selected() else { + return; + }; + let Some((name, _, set)) = WEIGHT_FIELDS.get(idx) else { + return; + }; + let value: f32 = match self.weight_input_buffer.trim().parse() { + Ok(v) => v, + Err(_) => { + self.status = format!("'{}' is not a valid number", self.weight_input_buffer); + return; + } + }; + let Some(profile) = &mut self.profile_detail else { + return; + }; + set(&mut profile.weights, value); + let profile_id = profile.id; + let weights = profile.weights; + + match self + .client + .update_quality_profile_weights(profile_id, weights) + .await + { + Ok(()) => { + self.status = format!("{name} updated to {value}"); + self.weight_input_buffer.clear(); + self.focus = Focus::List; + } + Err(e) => self.status = format!("weight update failed: {e}"), + } + } + + pub async fn toggle_monitor_selected(&mut self) { + let Some(detail) = &self.detail else { + return; + }; + let id = detail.id; + let result = if detail.monitored { + self.client.unmonitor(id).await + } else { + self.client.monitor(id).await + }; + match result { + Ok(()) => { + self.status = "monitor state updated".to_string(); + if let Ok(fresh) = self.client.media_detail(id).await { + self.detail = Some(fresh); + } + } + Err(e) => self.status = format!("monitor toggle failed: {e}"), + } + } + + /// First call arms the confirmation; a second call while armed actually + /// deletes. Any other keypress (see `main.rs`) clears the pending state. + pub async fn delete_selected(&mut self) { + let Some(id) = self.detail.as_ref().map(|d| d.id) else { + return; + }; + if !self.confirm_delete { + self.confirm_delete = true; + self.status = "press x again to confirm delete".to_string(); + return; + } + self.confirm_delete = false; + match self.client.delete_media(id).await { + Ok(()) => { + self.status = "deleted".to_string(); + self.detail = None; + self.media_items = self.client.list_media().await.unwrap_or_default(); + } + Err(e) => self.status = format!("delete failed: {e}"), + } + } + + /// Deletes the imported file for whatever's selected — the movie + /// itself if the open detail is a movie, or the currently-selected + /// episode otherwise — freeing it to be grabbed again. Same + /// arm-then-confirm pattern as `delete_selected`, via + /// `confirm_delete_file` instead so the two don't cross-arm. + pub async fn delete_selected_file(&mut self) { + let Some(detail) = &self.detail else { + return; + }; + if !self.confirm_delete_file { + self.confirm_delete_file = true; + self.status = "press d again to confirm deleting this file".to_string(); + return; + } + self.confirm_delete_file = false; + + let result = if detail.kind == "movie" { + self.client.delete_movie_file(detail.id).await + } else { + let Some(idx) = self.episode_state.selected() else { + return; + }; + let Some(episode) = detail.episodes.get(idx) else { + return; + }; + self.client.delete_episode_file(episode.id).await + }; + match result { + Ok(()) => { + self.status = "file deleted, will be re-searched".to_string(); + if let Some(id) = self.selected_media_id() { + if let Ok(fresh) = self.client.media_detail(id).await { + self.detail = Some(fresh); + } + } + } + Err(e) => self.status = format!("file delete failed: {e}"), + } + } +} diff --git a/breadarr-tui/src/main.rs b/breadarr-tui/src/main.rs new file mode 100644 index 0000000..bfff7bd --- /dev/null +++ b/breadarr-tui/src/main.rs @@ -0,0 +1,187 @@ +mod app; +mod ui; + +use std::io; +use std::time::Duration; + +use anyhow::Result; +use breadarr_shared::{Config, DaemonClient}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; + +use app::{App, Focus, Tab}; + +#[tokio::main] +async fn main() -> Result<()> { + let config = Config::load()?; + let base_url = format!("http://{}", config.daemon.listen_addr); + let client = DaemonClient::new(base_url, &config.daemon.api_token); + let root_folder = config.default_root_folder().to_string_lossy().to_string(); + + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut app = App::new(client); + let result = run(&mut terminal, &mut app, &root_folder).await; + + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + result +} + +async fn run( + terminal: &mut Terminal>, + app: &mut App, + root_folder: &str, +) -> Result<()> { + let mut last_refresh = tokio::time::Instant::now() - Duration::from_secs(10); + + loop { + if last_refresh.elapsed() >= Duration::from_secs(3) { + app.refresh_active_tab().await; + last_refresh = tokio::time::Instant::now(); + } + + terminal.draw(|frame| ui::draw(frame, app))?; + + if event::poll(Duration::from_millis(200))? { + if let Event::Key(key) = event::read()? { + if key.kind != KeyEventKind::Press { + continue; + } + handle_key(app, key.code, root_folder).await; + if app.should_quit { + return Ok(()); + } + } + } + } +} + +async fn handle_key(app: &mut App, code: KeyCode, root_folder: &str) { + // Typing into the add-show search box takes priority over global keys. + if matches!(app.tab, Tab::Add) && matches!(app.focus, Focus::AddSearchInput) { + match code { + KeyCode::Enter => app.run_add_search().await, + KeyCode::Char(c) => app.add_query.push(c), + KeyCode::Backspace => { + app.add_query.pop(); + } + KeyCode::Esc => app.should_quit = true, + KeyCode::Tab => cycle_tab(app), + _ => {} + } + return; + } + + // Typing a replacement value for the selected weight axis, same + // priority-over-global-keys reasoning as the add-show search box above. + if matches!(app.tab, Tab::Profiles) && matches!(app.focus, Focus::WeightInput) { + match code { + KeyCode::Enter => app.commit_weight_edit().await, + KeyCode::Char(c) if c.is_ascii_digit() || c == '.' || c == '-' => { + app.weight_input_buffer.push(c); + } + KeyCode::Backspace => { + app.weight_input_buffer.pop(); + } + KeyCode::Esc => app.cancel_weight_edit(), + _ => {} + } + return; + } + + // Any key other than a second `x`/`d` clears a pending delete + // confirmation — the confirmation must be the very next keypress, not + // just "any keypress before the user gets distracted." + if app.confirm_delete && code != KeyCode::Char('x') { + app.confirm_delete = false; + } + if app.confirm_delete_file && code != KeyCode::Char('d') { + app.confirm_delete_file = false; + } + + match code { + KeyCode::Char('q') => app.should_quit = true, + KeyCode::Tab => cycle_tab(app), + KeyCode::Char('j') | KeyCode::Down => app.move_selection(1), + KeyCode::Char('k') | KeyCode::Up => app.move_selection(-1), + KeyCode::Esc => match app.tab { + Tab::Library if matches!(app.focus, Focus::Candidates) => app.close_candidates(), + Tab::Library if app.detail.is_some() => app.close_detail(), + Tab::Add => app.focus = Focus::AddSearchInput, + Tab::Profiles if app.profile_detail.is_some() => app.close_profile_detail(), + _ => {} + }, + KeyCode::Enter => match app.tab { + Tab::Library if matches!(app.focus, Focus::Candidates) => { + app.grab_selected_candidate().await; + } + Tab::Library => app.open_detail().await, + Tab::Add => match app.focus { + Focus::AddResults => app.add_selected_search_result(root_folder).await, + _ => app.focus = Focus::AddSearchInput, + }, + Tab::Profiles if app.profile_detail.is_some() => { + app.start_editing_selected_weight(); + } + Tab::Profiles => app.open_profile_detail(), + _ => {} + }, + KeyCode::Char('a') if matches!(app.tab, Tab::Review) => { + app.approve_selected_review().await; + } + KeyCode::Char('r') if matches!(app.tab, Tab::Review) => { + app.reject_selected_review().await; + } + KeyCode::Char('s') if matches!(app.tab, Tab::Library) && app.detail.is_some() => { + app.search_now_selected().await; + } + KeyCode::Char('m') if matches!(app.tab, Tab::Library) && app.detail.is_some() => { + app.toggle_monitor_selected().await; + } + KeyCode::Char('e') if matches!(app.tab, Tab::Library) && app.detail.is_some() => { + app.toggle_monitor_selected_episode().await; + } + KeyCode::Char('S') if matches!(app.tab, Tab::Library) && app.detail.is_some() => { + app.toggle_monitor_selected_season().await; + } + KeyCode::Char('x') if matches!(app.tab, Tab::Library) && app.detail.is_some() => { + app.delete_selected().await; + } + KeyCode::Char('d') if matches!(app.tab, Tab::Library) && app.detail.is_some() => { + app.delete_selected_file().await; + } + KeyCode::Char('c') + if matches!(app.tab, Tab::Library) + && app.detail.is_some() + && !matches!(app.focus, Focus::Candidates) => + { + app.fetch_candidates_for_selected().await; + } + _ => {} + } +} + +fn cycle_tab(app: &mut App) { + let idx = Tab::ALL.iter().position(|t| *t == app.tab).unwrap_or(0); + app.tab = Tab::ALL[(idx + 1) % Tab::ALL.len()]; + app.detail = None; + app.profile_detail = None; + app.profile_weight_state.select(None); + app.focus = if matches!(app.tab, Tab::Add) { + Focus::AddSearchInput + } else { + Focus::List + }; +} diff --git a/breadarr-tui/src/ui.rs b/breadarr-tui/src/ui.rs new file mode 100644 index 0000000..20d6910 --- /dev/null +++ b/breadarr-tui/src/ui.rs @@ -0,0 +1,559 @@ +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Tabs}; +use ratatui::Frame; + +use crate::app::{App, Focus, Tab}; + +pub fn draw(frame: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(3), + Constraint::Length(3), + ]) + .split(frame.area()); + + draw_tabs(frame, chunks[0], app); + + match app.tab { + Tab::Library => draw_library(frame, chunks[1], app), + Tab::History => draw_history(frame, chunks[1], app), + Tab::Review => draw_review(frame, chunks[1], app), + Tab::Add => draw_add(frame, chunks[1], app), + Tab::Stuck => draw_stuck(frame, chunks[1], app), + Tab::Calendar => draw_calendar(frame, chunks[1], app), + Tab::LibraryHealth => draw_library_health(frame, chunks[1], app), + Tab::Profiles => draw_profiles(frame, chunks[1], app), + } + + draw_status(frame, chunks[2], app); +} + +fn draw_tabs(frame: &mut Frame, area: Rect, app: &App) { + let titles: Vec = Tab::ALL.iter().map(|t| Line::from(t.title())).collect(); + let selected = Tab::ALL.iter().position(|t| *t == app.tab).unwrap_or(0); + + let (daemon_label, daemon_color) = match (&app.daemon_up, &app.health) { + (true, Some(h)) if h.search_halted => { + ("daemon: UP — ⚠ search halted".to_string(), Color::Yellow) + } + (true, Some(h)) => { + let any_failed = [ + &h.last_grab_cycle, + &h.last_import_cycle, + &h.last_search_cycle, + &h.last_upgrade_cycle, + ] + .into_iter() + .flatten() + .any(|c| !c.ok); + if any_failed { + ( + "daemon: UP — last cycle had errors".to_string(), + Color::Yellow, + ) + } else { + ("daemon: UP".to_string(), Color::Green) + } + } + (true, None) => ("daemon: UP".to_string(), Color::Green), + (false, _) => ("daemon: DOWN".to_string(), Color::Red), + }; + + let tabs = Tabs::new(titles) + .block(Block::default().borders(Borders::ALL).title(Span::styled( + daemon_label, + Style::default().fg(daemon_color), + ))) + .select(selected) + .highlight_style( + Style::default() + .add_modifier(Modifier::BOLD) + .fg(Color::Cyan), + ); + frame.render_widget(tabs, area); +} + +fn draw_library(frame: &mut Frame, area: Rect, app: &App) { + if let Some(detail) = &app.detail { + if matches!(app.focus, Focus::Candidates) { + draw_candidates(frame, area, app, &detail.title); + return; + } + + let items: Vec = detail + .episodes + .iter() + .map(|e| { + let status = if e.has_file { + "✓" + } else if e.monitored { + "…" + } else { + "-" + }; + let title = e.title.as_deref().unwrap_or(""); + ListItem::new(format!( + "{status} S{:02}E{:02} {title}", + e.season_number, e.episode_number + )) + }) + .collect(); + let monitor_label = if detail.monitored { + "monitored" + } else { + "unmonitored" + }; + let confirm = if app.confirm_delete { + " — x AGAIN TO DELETE SHOW" + } else if app.confirm_delete_file { + " — d AGAIN TO DELETE FILE" + } else { + "" + }; + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(format!( + "{} ({}) [{monitor_label}] — Esc: back s: search now m: monitor show \ + e: monitor episode S: monitor season x: delete show d: delete file \ + c: pick release{confirm}", + detail.title, + detail.year.map(|y| y.to_string()).unwrap_or_default() + ))) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + let mut state = app.episode_state.clone(); + frame.render_stateful_widget(list, area, &mut state); + return; + } + + let items: Vec = app + .media_items + .iter() + .map(|m| { + let missing = if m.missing_count > 0 { + format!(" — {} missing", m.missing_count) + } else { + String::new() + }; + ListItem::new(format!( + "{} ({}){}", + m.title, + m.year.map(|y| y.to_string()).unwrap_or_default(), + missing + )) + }) + .collect(); + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title("Monitored Shows — Enter for detail"), + ) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + let mut state = app.media_state.clone(); + frame.render_stateful_widget(list, area, &mut state); +} + +/// Manual release picker — candidates for whatever episode/movie was +/// selected when `c` was pressed, scored (or gate-rejected with a reason) +/// exactly like the automatic search pipeline would see them. +fn draw_candidates(frame: &mut Frame, area: Rect, app: &App, media_title: &str) { + if app.candidates.is_empty() { + let placeholder = Paragraph::new("loading candidates...").block( + Block::default() + .borders(Borders::ALL) + .title(format!("{media_title} — candidates — Esc: cancel")), + ); + frame.render_widget(placeholder, area); + return; + } + + let items: Vec = app + .candidates + .iter() + .map(|c| { + let size = c + .size_bytes + .map(|b| format!("{:.2} GB", b as f64 / 1_073_741_824.0)) + .unwrap_or_else(|| "?".to_string()); + let seeders = c + .seeders + .map(|s| s.to_string()) + .unwrap_or_else(|| "?".to_string()); + let flags = format!( + "{}{}", + if c.is_season_pack { " [PACK]" } else { "" }, + if c.is_repack { " [REPACK]" } else { "" }, + ); + let verdict = match (c.score, &c.rejected_reason) { + (Some(score), _) => format!("score {score:.1}"), + (None, Some(reason)) => format!("REJECTED: {reason}"), + (None, None) => "unscored".to_string(), + }; + ListItem::new(format!( + "[{}] {} — {seeders} seeders, {size}{flags} — {verdict}", + c.source_name, c.raw_title + )) + }) + .collect(); + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(format!( + "{media_title} — candidates — Enter: grab Esc: cancel" + ))) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + let mut state = app.candidates_state.clone(); + frame.render_stateful_widget(list, area, &mut state); +} + +fn draw_history(frame: &mut Frame, area: Rect, app: &App) { + let items: Vec = app + .releases + .iter() + .map(|r| { + ListItem::new(format!( + "[{}] {} — {} (score {:.1})", + r.status, + r.media_title, + r.raw_title, + r.score.unwrap_or(0.0) + )) + }) + .collect(); + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title("Grab History")) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + let mut state = app.releases_state.clone(); + frame.render_stateful_widget(list, area, &mut state); +} + +fn draw_review(frame: &mut Frame, area: Rect, app: &App) { + let items: Vec = app + .review_items + .iter() + .map(|r| { + ListItem::new(format!( + "({:.0}%) {} -> {}", + r.confidence * 100.0, + r.raw_release_title, + r.candidate_media_title.as_deref().unwrap_or("?") + )) + }) + .collect(); + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title("Review Queue — a: approve, r: reject"), + ) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + let mut state = app.review_state.clone(); + frame.render_stateful_widget(list, area, &mut state); +} + +/// "Why don't I have this yet" — grabs sitting without importing longer +/// than expected, how deep the review queue has backed up, and search +/// targets that have been failing every attempt long enough for their +/// backoff to hit its ceiling. Read-only report, no selection/navigation. +fn draw_stuck(frame: &mut Frame, area: Rect, app: &App) { + let Some(report) = &app.stuck else { + let placeholder = Paragraph::new("loading...").block( + Block::default() + .borders(Borders::ALL) + .title("Stuck — why don't I have this yet"), + ); + frame.render_widget(placeholder, area); + return; + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Min(3)]) + .split(area); + + let stalled_items: Vec = report + .stalled_grabs + .iter() + .map(|g| { + ListItem::new(format!( + "{} — {} (grabbed {})", + g.media_title, g.raw_title, g.grabbed_at + )) + }) + .collect(); + let stalled_list = + List::new(stalled_items).block(Block::default().borders(Borders::ALL).title(format!( + "Stalled grabs ({}) — review queue: {} pending", + report.stalled_grabs.len(), + report.review_queue_depth + ))); + frame.render_widget(stalled_list, chunks[0]); + + let maxed_items: Vec = report + .maxed_out_search_targets + .iter() + .map(|t| { + ListItem::new(format!( + "{} — {} attempts, last searched {}", + t.media_title, + t.search_count, + t.last_searched_at.as_deref().unwrap_or("never") + )) + }) + .collect(); + let maxed_list = List::new(maxed_items).block( + Block::default() + .borders(Borders::ALL) + .title("Search targets at max backoff"), + ); + frame.render_widget(maxed_list, chunks[1]); +} + +fn draw_add(frame: &mut Frame, area: Rect, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(3)]) + .split(area); + + let input_style = match app.focus { + Focus::AddSearchInput => Style::default().fg(Color::Cyan), + _ => Style::default(), + }; + let input = Paragraph::new(app.add_query.as_str()) + .style(input_style) + .block( + Block::default() + .borders(Borders::ALL) + .title("Search — Enter to search movies and TV together"), + ); + frame.render_widget(input, chunks[0]); + + let items: Vec = app + .add_results + .iter() + .map(|r| { + ListItem::new(format!( + "[{}] {} ({})", + r.kind.label(), + r.result.title, + r.result.year.map(|y| y.to_string()).unwrap_or_default() + )) + }) + .collect(); + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title("Results — Enter to add"), + ) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + let mut state = app.add_results_state.clone(); + frame.render_stateful_widget(list, chunks[1], &mut state); +} + +/// What's aired recently or airs soon (a week back, three weeks forward — +/// see `calendar::DAYS_PAST`/`DAYS_FUTURE` server-side). Read-only, no +/// selection — a lookahead view, not something acted on directly here. +fn draw_calendar(frame: &mut Frame, area: Rect, app: &App) { + let today = chrono::Local::now().date_naive().to_string(); + let items: Vec = app + .calendar + .iter() + .map(|e| { + let status = if e.has_file { + "✓" + } else if !e.monitored { + "-" + } else if e.air_date.as_str() > today.as_str() { + "…" + } else { + "!" // aired, monitored, still missing + }; + let title = e.title.as_deref().unwrap_or(""); + ListItem::new(format!( + "{status} {} {} S{:02}E{:02} {title}", + e.air_date, e.media_title, e.season_number, e.episode_number + )) + }) + .collect(); + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .title("Calendar — ✓ have it … upcoming ! aired but missing"), + ); + frame.render_widget(list, area); +} + +/// Read-only report on `media_file_probe` state: corruption, under-quality, +/// missing-subtitle/English-audio, non-English-default-audio, and duplicate +/// files, plus a library-wide summary — no selection/navigation, same shape +/// as `draw_stuck`. +fn draw_library_health(frame: &mut Frame, area: Rect, app: &App) { + let Some(report) = &app.library_health else { + let placeholder = Paragraph::new("loading...").block( + Block::default() + .borders(Borders::ALL) + .title("Library Health"), + ); + frame.render_widget(placeholder, area); + return; + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(5), Constraint::Min(3)]) + .split(area); + + let s = &report.summary; + let codec_summary = s + .by_video_codec + .iter() + .map(|c| format!("{}={}", c.codec, c.count)) + .collect::>() + .join(", "); + let summary_text = format!( + "{} files, {:.1} GB total, {} probed — resolution: SD={} 720p={} 1080p={} 4K={} \ + — subtitles: {:.0}% — codecs: {codec_summary}", + s.total_files, + s.total_size_bytes as f64 / 1_073_741_824.0, + s.probed_files, + s.sd_count, + s.hd_720p_count, + s.full_hd_1080p_count, + s.uhd_4k_count, + s.pct_with_subtitles, + ); + let summary = Paragraph::new(summary_text) + .wrap(ratatui::widgets::Wrap { trim: true }) + .block( + Block::default() + .borders(Borders::ALL) + .title("Library summary"), + ); + frame.render_widget(summary, chunks[0]); + + let mut items: Vec = Vec::new(); + let mut section = |label: &str, files: &[breadarr_shared::dto::FlaggedFile]| { + if files.is_empty() { + return; + } + items.push(ListItem::new(Span::styled( + format!("── {label} ({}) ──", files.len()), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + for f in files { + let label = f.episode_label.as_deref().unwrap_or(""); + items.push(ListItem::new(format!( + " {} {label} — {}", + f.media_title, f.path + ))); + } + }; + section("Corrupt / unreadable", &report.corrupt_files); + section("Under 1080p", &report.under_quality_files); + section("No English audio", &report.no_english_audio_files); + section( + "Non-English default audio", + &report.non_english_default_audio_files, + ); + section("No subtitles", &report.no_subtitle_files); + + if !report.duplicate_groups.is_empty() { + items.push(ListItem::new(Span::styled( + format!("── Duplicate files ({}) ──", report.duplicate_groups.len()), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + for g in &report.duplicate_groups { + let label = g.episode_label.as_deref().unwrap_or(""); + items.push(ListItem::new(format!( + " {} {label} — {} copies", + g.media_title, + g.paths.len() + ))); + } + } + + if items.is_empty() { + items.push(ListItem::new("Nothing flagged — library looks clean.")); + } + + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .title("Flagged files"), + ); + frame.render_widget(list, chunks[1]); +} + +/// Quality-profile weight editing — list of profiles, then (once one is +/// opened) a flat list of its 9 scoring axes with an inline text-input +/// overlay while `Focus::WeightInput` is active. +fn draw_profiles(frame: &mut Frame, area: Rect, app: &App) { + let Some(profile) = &app.profile_detail else { + let items: Vec = app + .quality_profiles + .iter() + .map(|p| ListItem::new(format!("{} ({})", p.name, p.kind))) + .collect(); + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title("Quality Profiles — Enter: edit weights"), + ) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + let mut state = app.profiles_state.clone(); + frame.render_stateful_widget(list, area, &mut state); + return; + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(3)]) + .split(area); + + let editing_label = app + .profile_weight_state + .selected() + .and_then(|idx| crate::app::WEIGHT_FIELDS.get(idx)) + .map(|(name, ..)| *name) + .unwrap_or(""); + let input_style = match app.focus { + Focus::WeightInput => Style::default().fg(Color::Cyan), + _ => Style::default(), + }; + let input = Paragraph::new(app.weight_input_buffer.as_str()) + .style(input_style) + .block(Block::default().borders(Borders::ALL).title(format!( + "Editing {editing_label} — Enter: save Esc: cancel" + ))); + frame.render_widget(input, chunks[0]); + + let items: Vec = crate::app::WEIGHT_FIELDS + .iter() + .map(|(name, get, _)| ListItem::new(format!("{name}: {}", get(&profile.weights)))) + .collect(); + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(format!( + "{} — Enter: edit selected Esc: back", + profile.name + ))) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + let mut state = app.profile_weight_state.clone(); + frame.render_stateful_widget(list, chunks[1], &mut state); +} + +fn draw_status(frame: &mut Frame, area: Rect, app: &App) { + let text = if app.status.is_empty() { + "Tab: switch view | j/k: move | q: quit".to_string() + } else { + app.status.clone() + }; + let status = Paragraph::new(text).block(Block::default().borders(Borders::ALL)); + frame.render_widget(status, area); +} diff --git a/breadarrd/Cargo.toml b/breadarrd/Cargo.toml new file mode 100644 index 0000000..77824c6 --- /dev/null +++ b/breadarrd/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "breadarrd" +version = "0.1.0" +edition = "2021" + +[dependencies] +breadarr-shared.workspace = true +tokio.workspace = true +anyhow.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +axum.workspace = true +serde.workspace = true +reqwest.workspace = true +rusqlite.workspace = true +quick-xml.workspace = true +async-trait.workspace = true +regex.workspace = true +serde_json.workspace = true +ort.workspace = true +tokenizers.workspace = true +scraper.workspace = true +chrono.workspace = true +fastrand.workspace = true +nix.workspace = true diff --git a/breadarrd/src/api/mod.rs b/breadarrd/src/api/mod.rs new file mode 100644 index 0000000..2c6c309 --- /dev/null +++ b/breadarrd/src/api/mod.rs @@ -0,0 +1,180 @@ +pub mod routes; + +use std::sync::Arc; + +use axum::extract::{Request, State}; +use axum::http::StatusCode; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::Router; +use rusqlite::Connection; +use tokio::sync::Mutex; + +use crate::metadata::tmdb::TmdbClient; +use crate::metadata::tvdb::TvdbClient; +use crate::qbit::QbitClient; +use crate::scheduler::SearchCycleStats; + +/// A `tokio::sync::Mutex`, not `std::sync::Mutex` — several handlers (e.g. +/// review-queue approval) interleave synchronous DB calls with `.await`ed +/// qBittorrent/TVDB calls, and a sync `MutexGuard` can't be held across an +/// await point. This one can. +#[derive(Clone)] +pub struct AppState { + pub conn: Arc>, + pub tvdb: Option>, + pub tmdb: Option>, + pub qbit: Option>, + pub qbit_category: String, + pub cycle_status: Arc>, + pub config: breadarr_shared::Config, + /// `None` when the background loop isn't running (qbit not + /// configured) — routes that need the search pipeline (manual + /// "search now", the candidate picker) dispatch through this rather + /// than running it inline, since that pipeline holds a `Connection`/ + /// `&dyn ReleaseSource` across `.await` points and is therefore + /// `!Send`, which axum's `Handler` trait doesn't allow. Routing + /// through the loop's own already-running instance also means these + /// reuse its already-loaded title-matcher/sources for free. + pub background_tx: Option>, +} + +/// Everything an API handler needs the background loop to do on its +/// behalf, because the work involves `!Send` state (see `AppState:: +/// background_tx`'s doc comment) that can't be run directly inside an +/// axum handler. +pub enum BackgroundRequest { + SearchNow { + media_item_id: i64, + reply: tokio::sync::oneshot::Sender>, + }, + FetchCandidates { + media_item_id: i64, + episode_id: Option, + reply: tokio::sync::oneshot::Sender< + anyhow::Result>, + >, + }, + GrabCandidate { + media_item_id: i64, + episode_id: Option, + source_id: i64, + raw_title: String, + link: String, + guid: String, + reply: tokio::sync::oneshot::Sender>, + }, +} + +/// One record per completed grab/import cycle attempt — plain `std::sync:: +/// Mutex` is fine here (unlike `conn`) since updates are a single field +/// write with no `.await` in between. Exists so a silently-stalled +/// background loop (e.g. every cycle erroring for hours) is visible from a +/// single `/health` call instead of only in the journal. +#[derive(Clone, Default)] +pub struct CycleStatus { + pub last_grab: Option, + pub last_import: Option, + pub last_search: Option, + pub last_upgrade: Option, + /// Set once the search-driven loop's consecutive-failure backoff hits + /// its ceiling — still ticking at max backoff underneath (self-healing + /// if the source recovers), but worth a loud, easy-to-spot signal that + /// something's wrong with 1337x/nyaa-search specifically. + pub search_halted: bool, +} + +#[derive(Clone)] +pub struct CycleRecord { + pub at: chrono::DateTime, + pub ok: bool, + pub detail: String, +} + +/// Rejects any request lacking `Authorization: Bearer ` +/// once a token is actually configured — a no-op (every request passes) +/// when it's empty, so an unconfigured install behaves exactly as before. +/// `/health` is deliberately exempt even with a token configured: it's +/// commonly polled by external monitoring (e.g. an uptime dashboard) that +/// has no reason to hold the same credential as the TUI/API client, and it +/// exposes nothing more sensitive than "is the process alive." +async fn require_api_token(State(state): State, req: Request, next: Next) -> Response { + if state.config.daemon.api_token.is_empty() || req.uri().path() == "/health" { + return next.run(req).await; + } + let authorized = req + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .is_some_and(|token| token == state.config.daemon.api_token); + if !authorized { + return (StatusCode::UNAUTHORIZED, "missing or invalid API token").into_response(); + } + next.run(req).await +} + +pub fn router(state: AppState) -> Router { + Router::new() + .route("/health", get(routes::health::health)) + .route("/media", get(routes::media::list).post(routes::media::add)) + .route( + "/media/:id", + get(routes::media::detail).delete(routes::media::delete), + ) + .route("/media/movie", post(routes::media::add_movie)) + .route("/media/:id/search", post(routes::media::search_now)) + .route( + "/media/:id/candidates", + get(routes::media::list_candidates).post(routes::media::grab_candidate), + ) + .route( + "/episode/:id/candidates", + get(routes::media::list_episode_candidates).post(routes::media::grab_episode_candidate), + ) + .route("/media/:id/monitor", post(routes::media::monitor)) + .route("/media/:id/unmonitor", post(routes::media::unmonitor)) + .route( + "/media/:id/season/:season_number/monitor", + post(routes::media::monitor_season), + ) + .route( + "/media/:id/season/:season_number/unmonitor", + post(routes::media::unmonitor_season), + ) + .route("/episode/:id/monitor", post(routes::media::monitor_episode)) + .route( + "/episode/:id/unmonitor", + post(routes::media::unmonitor_episode), + ) + .route( + "/episode/:id/file", + axum::routing::delete(routes::media::delete_episode_file), + ) + .route( + "/media/:id/file", + axum::routing::delete(routes::media::delete_movie_file), + ) + .route("/releases", get(routes::releases::list)) + .route("/review", get(routes::review::list)) + .route("/review/:id/approve", post(routes::review::approve)) + .route("/review/:id/reject", post(routes::review::reject)) + .route("/search", get(routes::search::search)) + .route("/stuck", get(routes::stuck::stuck)) + .route("/calendar", get(routes::calendar::calendar)) + .route( + "/library/health", + get(routes::library_health::library_health), + ) + .route("/quality-profiles", get(routes::quality_profiles::list)) + .route( + "/quality-profiles/:id/weights", + axum::routing::put(routes::quality_profiles::update_weights), + ) + .layer(middleware::from_fn_with_state( + state.clone(), + require_api_token, + )) + .with_state(state) +} diff --git a/breadarrd/src/api/routes/calendar.rs b/breadarrd/src/api/routes/calendar.rs new file mode 100644 index 0000000..fec4871 --- /dev/null +++ b/breadarrd/src/api/routes/calendar.rs @@ -0,0 +1,52 @@ +use axum::extract::State; +use axum::http::StatusCode; +use axum::Json; +use breadarr_shared::dto::CalendarEntry; + +use crate::api::AppState; + +/// How far back/forward "upcoming" spans — matches the common Sonarr +/// calendar default window (a week either side) rather than an arbitrary +/// wider range, since the point is "what's relevant right now." +const DAYS_PAST: i64 = 7; +const DAYS_FUTURE: i64 = 21; + +pub async fn calendar( + State(state): State, +) -> Result>, (StatusCode, String)> { + let conn = state.conn.lock().await; + let mut stmt = conn + .prepare( + "SELECT e.media_item_id, m.title, e.season_number, e.episode_number, e.title, + e.air_date, e.monitored, e.has_file + FROM episode e JOIN media_item m ON m.id = e.media_item_id + WHERE e.air_date IS NOT NULL + AND date(e.air_date) BETWEEN date('now', ?1) AND date('now', ?2) + ORDER BY e.air_date ASC", + ) + .map_err(internal)?; + let rows = stmt + .query_map( + [format!("-{DAYS_PAST} days"), format!("+{DAYS_FUTURE} days")], + |row| { + Ok(CalendarEntry { + media_item_id: row.get(0)?, + media_title: row.get(1)?, + season_number: row.get(2)?, + episode_number: row.get(3)?, + title: row.get(4)?, + air_date: row.get(5)?, + monitored: row.get::<_, i64>(6)? != 0, + has_file: row.get::<_, i64>(7)? != 0, + }) + }, + ) + .map_err(internal)? + .collect::>>() + .map_err(internal)?; + Ok(Json(rows)) +} + +fn internal(e: E) -> (StatusCode, String) { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) +} diff --git a/breadarrd/src/api/routes/health.rs b/breadarrd/src/api/routes/health.rs new file mode 100644 index 0000000..4333efd --- /dev/null +++ b/breadarrd/src/api/routes/health.rs @@ -0,0 +1,25 @@ +use axum::extract::State; +use axum::Json; +use breadarr_shared::dto::{CycleInfo, HealthDetail}; + +use crate::api::AppState; + +fn to_info(r: &crate::api::CycleRecord) -> CycleInfo { + CycleInfo { + at: r.at, + ok: r.ok, + detail: r.detail.clone(), + } +} + +pub async fn health(State(state): State) -> Json { + let status = state.cycle_status.lock().expect("cycle_status poisoned"); + Json(HealthDetail { + status: "ok".to_string(), + last_grab_cycle: status.last_grab.as_ref().map(to_info), + last_import_cycle: status.last_import.as_ref().map(to_info), + last_search_cycle: status.last_search.as_ref().map(to_info), + last_upgrade_cycle: status.last_upgrade.as_ref().map(to_info), + search_halted: status.search_halted, + }) +} diff --git a/breadarrd/src/api/routes/library_health.rs b/breadarrd/src/api/routes/library_health.rs new file mode 100644 index 0000000..84c2df5 --- /dev/null +++ b/breadarrd/src/api/routes/library_health.rs @@ -0,0 +1,321 @@ +use axum::extract::State; +use axum::http::StatusCode; +use axum::Json; +use breadarr_shared::dto::{ + CodecCount, DuplicateGroup, FlaggedFile, LibraryHealthReport, LibrarySummary, +}; +use rusqlite::Connection; + +use crate::api::AppState; + +/// Every flag category shares the same shape (id/title/episode-label/path), +/// resolved through the same TV-or-movie join every time — `episode_id` is +/// set for a TV file (join via `episode`→`media_item`) and NULL for a movie +/// file (join `media_item` directly via `episode_file.media_item_id`), the +/// same nullable-episode_id convention used throughout this schema. +const FLAGGED_FILE_SELECT: &str = " + SELECT ef.id, + COALESCE(mi_direct.title, mi_ep.title) AS media_title, + CASE WHEN ef.episode_id IS NOT NULL + THEN 'S' || printf('%02d', e.season_number) || 'E' || printf('%02d', e.episode_number) + ELSE NULL END AS episode_label, + ef.path + FROM episode_file ef + JOIN media_file_probe p ON p.episode_file_id = ef.id + LEFT JOIN media_item mi_direct ON mi_direct.id = ef.media_item_id + LEFT JOIN episode e ON e.id = ef.episode_id + LEFT JOIN media_item mi_ep ON mi_ep.id = e.media_item_id + WHERE "; + +fn fetch_flagged(conn: &Connection, where_clause: &str) -> rusqlite::Result> { + let sql = format!("{FLAGGED_FILE_SELECT}{where_clause}"); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([], |row| { + Ok(FlaggedFile { + episode_file_id: row.get(0)?, + media_title: row.get(1)?, + episode_label: row.get(2)?, + path: row.get(3)?, + }) + })?; + rows.collect() +} + +fn fetch_duplicate_groups(conn: &Connection) -> rusqlite::Result> { + let mut groups = Vec::new(); + + let mut stmt = conn.prepare( + "SELECT m.title, + 'S' || printf('%02d', e.season_number) || 'E' || printf('%02d', e.episode_number), + group_concat(ef.path, '||') + FROM episode_file ef + JOIN episode e ON e.id = ef.episode_id + JOIN media_item m ON m.id = e.media_item_id + GROUP BY ef.episode_id + HAVING count(*) > 1", + )?; + groups.extend( + stmt.query_map([], |row| { + let paths: String = row.get(2)?; + Ok(DuplicateGroup { + media_title: row.get(0)?, + episode_label: row.get(1)?, + paths: paths.split("||").map(str::to_string).collect(), + }) + })? + .collect::>>()?, + ); + + let mut stmt = conn.prepare( + "SELECT m.title, group_concat(ef.path, '||') + FROM episode_file ef + JOIN media_item m ON m.id = ef.media_item_id + WHERE ef.episode_id IS NULL + GROUP BY ef.media_item_id + HAVING count(*) > 1", + )?; + groups.extend( + stmt.query_map([], |row| { + let paths: String = row.get(1)?; + Ok(DuplicateGroup { + media_title: row.get(0)?, + episode_label: None, + paths: paths.split("||").map(str::to_string).collect(), + }) + })? + .collect::>>()?, + ); + + Ok(groups) +} + +fn fetch_summary(conn: &Connection) -> rusqlite::Result { + let (total_files, total_size_bytes): (i64, i64) = conn.query_row( + "SELECT count(*), COALESCE(sum(size_bytes), 0) FROM episode_file", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + let probed_files: i64 = conn.query_row("SELECT count(*) FROM media_file_probe", [], |row| { + row.get(0) + })?; + + let mut stmt = conn.prepare( + "SELECT video_codec, count(*) FROM media_file_probe + WHERE video_codec IS NOT NULL GROUP BY video_codec ORDER BY count(*) DESC", + )?; + let by_video_codec = stmt + .query_map([], |row| { + Ok(CodecCount { + codec: row.get(0)?, + count: row.get(1)?, + }) + })? + .collect::>>()?; + + // Resolution buckets are computed against `height` directly rather than + // reusing `flag_under_quality` (which is only a <1080p boolean) — this + // view wants the full breakdown, not just the pass/fail split. + let bucket = |where_clause: &str| -> rusqlite::Result { + conn.query_row( + &format!( + "SELECT count(*) FROM media_file_probe WHERE height IS NOT NULL AND {where_clause}" + ), + [], + |row| row.get(0), + ) + }; + let sd_count = bucket("height < 720")?; + let hd_720p_count = bucket("height >= 720 AND height < 1080")?; + let full_hd_1080p_count = bucket("height >= 1080 AND height < 2160")?; + let uhd_4k_count = bucket("height >= 2160")?; + + let with_subs: i64 = conn.query_row( + "SELECT count(*) FROM media_file_probe WHERE flag_no_subtitles = 0", + [], + |row| row.get(0), + )?; + let pct_with_subtitles = if probed_files > 0 { + (with_subs as f64 / probed_files as f64) * 100.0 + } else { + 0.0 + }; + + Ok(LibrarySummary { + total_files, + total_size_bytes, + probed_files, + by_video_codec, + sd_count, + hd_720p_count, + full_hd_1080p_count, + uhd_4k_count, + pct_with_subtitles, + }) +} + +pub async fn library_health( + State(state): State, +) -> Result, (StatusCode, String)> { + let conn = state.conn.lock().await; + + let report = LibraryHealthReport { + corrupt_files: fetch_flagged( + &conn, + "p.corruption_status IN ('probe_failed','decode_failed')", + ) + .map_err(internal)?, + under_quality_files: fetch_flagged(&conn, "p.flag_under_quality = 1").map_err(internal)?, + no_subtitle_files: fetch_flagged(&conn, "p.flag_no_subtitles = 1").map_err(internal)?, + no_english_audio_files: fetch_flagged(&conn, "p.flag_no_english_audio = 1") + .map_err(internal)?, + non_english_default_audio_files: fetch_flagged( + &conn, + "p.flag_non_english_default_audio = 1", + ) + .map_err(internal)?, + duplicate_groups: fetch_duplicate_groups(&conn).map_err(internal)?, + summary: fetch_summary(&conn).map_err(internal)?, + }; + + Ok(Json(report)) +} + +fn internal(e: E) -> (StatusCode, String) { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A TV episode file (id=1, under-quality) and a movie file (id=2, + /// no English audio) with `media_file_probe` rows already populated — + /// exercises both halves of the nullable-episode_id join every query in + /// this file relies on. + fn seeded_conn() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (1, 'series', 'Some Show', 2020, 1, 1, '/tmp')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file) + VALUES (1, 1, 2, 5, 1, 1)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (1, 1, NULL, '/tmp/show.mkv', 1000, 'none')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO media_file_probe (episode_file_id, probed_at, probe_size_bytes, probe_mtime, height, video_codec, + corruption_status, flag_under_quality, flag_no_subtitles, flag_no_english_audio, flag_non_english_default_audio) + VALUES (1, datetime('now'), 1000, 0, 480, 'h264', 'probe_ok', 1, 0, 0, 0)", + [], + ) + .unwrap(); + + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (2, 'movie', 'Some Movie', 2019, 1, 2, '/tmp')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (2, NULL, 2, '/tmp/movie.mkv', 2000, 'none')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO media_file_probe (episode_file_id, probed_at, probe_size_bytes, probe_mtime, height, video_codec, + corruption_status, flag_under_quality, flag_no_subtitles, flag_no_english_audio, flag_non_english_default_audio) + VALUES (2, datetime('now'), 2000, 0, 1080, 'hevc', 'probe_ok', 0, 1, 1, 0)", + [], + ) + .unwrap(); + + conn + } + + #[test] + fn fetch_flagged_resolves_titles_for_both_tv_and_movie_files() { + let conn = seeded_conn(); + + let under_quality = fetch_flagged(&conn, "p.flag_under_quality = 1").unwrap(); + assert_eq!(under_quality.len(), 1); + assert_eq!(under_quality[0].media_title, "Some Show"); + assert_eq!(under_quality[0].episode_label.as_deref(), Some("S02E05")); + + let no_english = fetch_flagged(&conn, "p.flag_no_english_audio = 1").unwrap(); + assert_eq!(no_english.len(), 1); + assert_eq!(no_english[0].media_title, "Some Movie"); + assert_eq!(no_english[0].episode_label, None); + } + + #[test] + fn fetch_duplicate_groups_finds_both_tv_and_movie_duplicates() { + let conn = seeded_conn(); + // A second file for the same TV episode, and a second for the same movie. + conn.execute( + "INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (3, 1, NULL, '/tmp/show-dup.mkv', 1000, 'none')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (4, NULL, 2, '/tmp/movie-dup.mkv', 2000, 'none')", + [], + ) + .unwrap(); + + let groups = fetch_duplicate_groups(&conn).unwrap(); + assert_eq!(groups.len(), 2); + let tv_group = groups + .iter() + .find(|g| g.media_title == "Some Show") + .unwrap(); + assert_eq!(tv_group.paths.len(), 2); + let movie_group = groups + .iter() + .find(|g| g.media_title == "Some Movie") + .unwrap(); + assert_eq!(movie_group.paths.len(), 2); + } + + #[test] + fn fetch_duplicate_groups_ignores_files_without_duplicates() { + let conn = seeded_conn(); + assert!(fetch_duplicate_groups(&conn).unwrap().is_empty()); + } + + #[test] + fn fetch_summary_buckets_resolutions_and_computes_subtitle_percentage() { + let conn = seeded_conn(); + let summary = fetch_summary(&conn).unwrap(); + + assert_eq!(summary.total_files, 2); + assert_eq!(summary.total_size_bytes, 3000); + assert_eq!(summary.probed_files, 2); + assert_eq!(summary.sd_count, 1); // the 480p show episode + assert_eq!(summary.full_hd_1080p_count, 1); // the 1080p movie + // One of the two probed files (the movie) has flag_no_subtitles=1. + assert_eq!(summary.pct_with_subtitles, 50.0); + } + + #[test] + fn fetch_summary_handles_an_empty_library_without_dividing_by_zero() { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + let summary = fetch_summary(&conn).unwrap(); + assert_eq!(summary.total_files, 0); + assert_eq!(summary.pct_with_subtitles, 0.0); + } +} diff --git a/breadarrd/src/api/routes/media.rs b/breadarrd/src/api/routes/media.rs new file mode 100644 index 0000000..0c96885 --- /dev/null +++ b/breadarrd/src/api/routes/media.rs @@ -0,0 +1,507 @@ +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::Json; +use breadarr_shared::dto::{ + AddMovieRequest, AddMovieResponse, AddSeriesRequest, AddSeriesResponse, EpisodeSummary, + MediaItemDetail, MediaItemSummary, SearchNowResult, +}; +use rusqlite::{params, OptionalExtension}; + +use crate::api::AppState; +use crate::metadata; + +pub async fn list( + State(state): State, +) -> Result>, (StatusCode, String)> { + let conn = state.conn.lock().await; + let mut stmt = conn + .prepare( + "SELECT m.id, m.kind, m.title, m.year, m.monitored, + (SELECT count(*) FROM episode e WHERE e.media_item_id = m.id) AS episode_count, + (SELECT count(*) FROM episode e WHERE e.media_item_id = m.id AND e.monitored = 1 AND e.has_file = 0) AS missing_count + FROM media_item m ORDER BY m.title", + ) + .map_err(internal)?; + let rows = stmt + .query_map([], |row| { + Ok(MediaItemSummary { + id: row.get(0)?, + kind: row.get(1)?, + title: row.get(2)?, + year: row.get(3)?, + monitored: row.get::<_, i64>(4)? != 0, + episode_count: row.get(5)?, + missing_count: row.get(6)?, + }) + }) + .map_err(internal)? + .collect::>>() + .map_err(internal)?; + Ok(Json(rows)) +} + +pub async fn detail( + State(state): State, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let conn = state.conn.lock().await; + let (kind, title, year, monitored, root_folder) = conn + .query_row( + "SELECT kind, title, year, monitored, root_folder FROM media_item WHERE id = ?1", + params![id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, i64>(3)? != 0, + row.get::<_, String>(4)?, + )) + }, + ) + .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?; + + let mut stmt = conn + .prepare( + "SELECT id, season_number, episode_number, title, air_date, monitored, has_file + FROM episode WHERE media_item_id = ?1 ORDER BY season_number, episode_number", + ) + .map_err(internal)?; + let episodes = stmt + .query_map(params![id], |row| { + Ok(EpisodeSummary { + id: row.get(0)?, + season_number: row.get(1)?, + episode_number: row.get(2)?, + title: row.get(3)?, + air_date: row.get(4)?, + monitored: row.get::<_, i64>(5)? != 0, + has_file: row.get::<_, i64>(6)? != 0, + }) + }) + .map_err(internal)? + .collect::>>() + .map_err(internal)?; + + Ok(Json(MediaItemDetail { + id, + kind, + title, + year, + monitored, + root_folder, + episodes, + })) +} + +pub async fn add( + State(state): State, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let Some(tvdb) = &state.tvdb else { + return Err(( + StatusCode::PRECONDITION_FAILED, + "tvdb.api_key is not configured".into(), + )); + }; + + // Fetch before taking the lock — a sync MutexGuard can't be held across + // an `.await` point. + let episodes = tvdb.episodes(&req.tvdb_id).await.map_err(internal)?; + + let media_item_id = { + let conn = state.conn.lock().await; + metadata::insert_series( + &conn, + &req.tvdb_id, + &req.title, + req.year.map(|y| y as u32), + &req.aliases, + &req.root_folder, + 1, + &episodes, + ) + .map_err(internal)? + }; + + Ok(Json(AddSeriesResponse { media_item_id })) +} + +pub async fn add_movie( + State(state): State, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let conn = state.conn.lock().await; + let media_item_id = metadata::insert_movie( + &conn, + &req.tmdb_id, + &req.title, + req.year.map(|y| y as u32), + &req.root_folder, + 2, + ) + .map_err(internal)?; + Ok(Json(AddMovieResponse { media_item_id })) +} + +pub async fn monitor( + State(state): State, + Path(id): Path, +) -> Result { + set_monitored(&state, id, true).await +} + +pub async fn unmonitor( + State(state): State, + Path(id): Path, +) -> Result { + set_monitored(&state, id, false).await +} + +async fn set_monitored( + state: &AppState, + id: i64, + monitored: bool, +) -> Result { + let conn = state.conn.lock().await; + let rows = conn + .execute( + "UPDATE media_item SET monitored = ?1 WHERE id = ?2", + params![monitored as i64, id], + ) + .map_err(internal)?; + if rows == 0 { + return Err((StatusCode::NOT_FOUND, format!("no media_item {id}"))); + } + Ok(StatusCode::NO_CONTENT) +} + +pub async fn monitor_episode( + State(state): State, + Path(id): Path, +) -> Result { + set_episode_monitored(&state, id, true).await +} + +pub async fn unmonitor_episode( + State(state): State, + Path(id): Path, +) -> Result { + set_episode_monitored(&state, id, false).await +} + +async fn set_episode_monitored( + state: &AppState, + episode_id: i64, + monitored: bool, +) -> Result { + let conn = state.conn.lock().await; + let rows = conn + .execute( + "UPDATE episode SET monitored = ?1 WHERE id = ?2", + params![monitored as i64, episode_id], + ) + .map_err(internal)?; + if rows == 0 { + return Err((StatusCode::NOT_FOUND, format!("no episode {episode_id}"))); + } + Ok(StatusCode::NO_CONTENT) +} + +/// Toggles every episode in one season at once — a season-level "row" isn't +/// separately tracked (the `season` table exists in the schema but was +/// never actually populated by any insert path, so resurrecting it just to +/// hold one redundant monitored flag would mean keeping two copies of the +/// same state in sync for no behavioral gain); bulk-updating the episodes +/// directly gets the identical practical effect — this season's episodes +/// stop appearing in search enumeration — with one source of truth. +pub async fn monitor_season( + State(state): State, + Path((media_item_id, season_number)): Path<(i64, i64)>, +) -> Result { + set_season_monitored(&state, media_item_id, season_number, true).await +} + +pub async fn unmonitor_season( + State(state): State, + Path((media_item_id, season_number)): Path<(i64, i64)>, +) -> Result { + set_season_monitored(&state, media_item_id, season_number, false).await +} + +async fn set_season_monitored( + state: &AppState, + media_item_id: i64, + season_number: i64, + monitored: bool, +) -> Result { + let conn = state.conn.lock().await; + let rows = conn + .execute( + "UPDATE episode SET monitored = ?1 WHERE media_item_id = ?2 AND season_number = ?3", + params![monitored as i64, media_item_id, season_number], + ) + .map_err(internal)?; + if rows == 0 { + return Err(( + StatusCode::NOT_FOUND, + format!("no episodes in media_item {media_item_id} season {season_number}"), + )); + } + Ok(StatusCode::NO_CONTENT) +} + +/// Removes a media item (and, via cascade, its episodes/aliases/releases) +/// from the library. Deliberately does not touch anything on disk — the +/// same "stop managing this, don't delete the user's files" default +/// Sonarr/Radarr use. +pub async fn delete( + State(state): State, + Path(id): Path, +) -> Result { + let conn = state.conn.lock().await; + let rows = conn + .execute("DELETE FROM media_item WHERE id = ?1", params![id]) + .map_err(internal)?; + if rows == 0 { + return Err((StatusCode::NOT_FOUND, format!("no media_item {id}"))); + } + Ok(StatusCode::NO_CONTENT) +} + +/// Deletes an episode's imported file — both the row *and* the actual file +/// on disk — and clears `has_file`, freeing the episode to be grabbed again +/// by a future search. Unlike `delete` above, this deliberately does touch +/// disk: the whole point is "this specific file is wrong/broken," and +/// leaving it in place would (a) mislead the user into thinking they still +/// have a good copy, and (b) block a future re-grab outright — `import_one` +/// refuses to overwrite an existing file with an equal-or-lower-scoring one +/// (see its dest-collision check), so a bad file that happened to score +/// high would silently reject its own replacement forever if left on disk. +pub async fn delete_episode_file( + State(state): State, + Path(episode_id): Path, +) -> Result { + let conn = state.conn.lock().await; + let row: Option<(i64, String)> = conn + .query_row( + "SELECT id, path FROM episode_file WHERE episode_id = ?1", + params![episode_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(internal)?; + let Some((file_id, path)) = row else { + return Err(( + StatusCode::NOT_FOUND, + format!("no file tracked for episode {episode_id}"), + )); + }; + delete_file_and_clear(&conn, &path, file_id)?; + conn.execute( + "UPDATE episode SET has_file = 0 WHERE id = ?1", + params![episode_id], + ) + .map_err(internal)?; + Ok(StatusCode::NO_CONTENT) +} + +/// Movie counterpart to `delete_episode_file` — movies have no `has_file` +/// column of their own (`movie_needs_grab` derives "has a file" purely from +/// `episode_file` row existence), so clearing the row is the entire state +/// reset needed. +pub async fn delete_movie_file( + State(state): State, + Path(media_item_id): Path, +) -> Result { + let conn = state.conn.lock().await; + let row: Option<(i64, String)> = conn + .query_row( + "SELECT id, path FROM episode_file WHERE media_item_id = ?1 AND episode_id IS NULL", + params![media_item_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(internal)?; + let Some((file_id, path)) = row else { + return Err(( + StatusCode::NOT_FOUND, + format!("no file tracked for media_item {media_item_id}"), + )); + }; + delete_file_and_clear(&conn, &path, file_id)?; + Ok(StatusCode::NO_CONTENT) +} + +/// Shared cleanup: removes the file from disk (tolerating "already gone" — +/// nothing left to do, not an error) and deletes its `episode_file` row by +/// that row's own primary key — never by `episode_id`/`media_item_id` +/// alone, which a TV show shares across every one of its episode rows and +/// would otherwise risk wiping an entire show's tracked files instead of +/// the one the caller actually looked up. +fn delete_file_and_clear( + conn: &rusqlite::Connection, + path: &str, + file_id: i64, +) -> Result<(), (StatusCode, String)> { + if let Err(e) = std::fs::remove_file(path) { + if e.kind() != std::io::ErrorKind::NotFound { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("failed to delete {path}: {e}"), + )); + } + } + conn.execute("DELETE FROM episode_file WHERE id = ?1", params![file_id]) + .map_err(internal)?; + Ok(()) +} + +/// Manual "search now" for a single show/movie's whole current backlog — +/// bypasses the background loop's per-cycle budget and due-ness cadence +/// (those exist to pace unattended, indefinite operation; a single +/// user-triggered request doesn't need throttling against itself). +/// +/// Dispatched to the background loop via a channel rather than run inline +/// here: the search pipeline (`execute_search_targets` and everything it +/// calls) holds a `Connection`/`&dyn ReleaseSource` across `.await` points, +/// which makes it `!Send` — fine for the background loop (part of the root +/// future, never `Send`-constrained) but not callable directly from an axum +/// handler (whose future axum requires to be `Send`). Routing through the +/// background loop's own already-running instance also means this reuses +/// its already-loaded title-matcher/sources instead of paying to construct +/// fresh ones per request. +pub async fn search_now( + State(state): State, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let Some(tx) = &state.background_tx else { + return Err(( + StatusCode::PRECONDITION_FAILED, + "background loop is not running (qbit.base_url is not configured)".into(), + )); + }; + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + tx.send(crate::api::BackgroundRequest::SearchNow { + media_item_id: id, + reply: reply_tx, + }) + .await + .map_err(internal)?; + let stats = reply_rx.await.map_err(internal)?.map_err(internal)?; + + Ok(Json(SearchNowResult { + targets: stats.targets, + searched: stats.searched, + grabbed: stats.grabbed, + errors: stats.errors, + source_exhausted: stats.source_exhausted, + })) +} + +/// Movie candidate picker: `episode_id` is always `None` for a movie +/// target, matching every other movie/episode split in this codebase. +pub async fn list_candidates( + State(state): State, + Path(id): Path, +) -> Result>, (StatusCode, String)> { + fetch_candidates_via_background(&state, id, None).await +} + +pub async fn grab_candidate( + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result { + grab_candidate_via_background(&state, id, None, req).await +} + +/// Episode candidate picker — resolves the episode's own `media_item_id` +/// first (every other candidate/search-target function needs it), same +/// pattern as `set_episode_monitored`. +pub async fn list_episode_candidates( + State(state): State, + Path(episode_id): Path, +) -> Result>, (StatusCode, String)> { + let media_item_id = episode_media_item_id(&state, episode_id).await?; + fetch_candidates_via_background(&state, media_item_id, Some(episode_id)).await +} + +pub async fn grab_episode_candidate( + State(state): State, + Path(episode_id): Path, + Json(req): Json, +) -> Result { + let media_item_id = episode_media_item_id(&state, episode_id).await?; + grab_candidate_via_background(&state, media_item_id, Some(episode_id), req).await +} + +async fn episode_media_item_id( + state: &AppState, + episode_id: i64, +) -> Result { + let conn = state.conn.lock().await; + conn.query_row( + "SELECT media_item_id FROM episode WHERE id = ?1", + params![episode_id], + |row| row.get(0), + ) + .optional() + .map_err(internal)? + .ok_or_else(|| (StatusCode::NOT_FOUND, format!("no episode {episode_id}"))) +} + +async fn fetch_candidates_via_background( + state: &AppState, + media_item_id: i64, + episode_id: Option, +) -> Result>, (StatusCode, String)> { + let Some(tx) = &state.background_tx else { + return Err(( + StatusCode::PRECONDITION_FAILED, + "background loop is not running (qbit.base_url is not configured)".into(), + )); + }; + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + tx.send(crate::api::BackgroundRequest::FetchCandidates { + media_item_id, + episode_id, + reply: reply_tx, + }) + .await + .map_err(internal)?; + let candidates = reply_rx.await.map_err(internal)?.map_err(internal)?; + Ok(Json(candidates)) +} + +async fn grab_candidate_via_background( + state: &AppState, + media_item_id: i64, + episode_id: Option, + req: breadarr_shared::dto::GrabCandidateRequest, +) -> Result { + let Some(tx) = &state.background_tx else { + return Err(( + StatusCode::PRECONDITION_FAILED, + "background loop is not running (qbit.base_url is not configured)".into(), + )); + }; + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + tx.send(crate::api::BackgroundRequest::GrabCandidate { + media_item_id, + episode_id, + source_id: req.source_id, + raw_title: req.raw_title, + link: req.link, + guid: req.guid, + reply: reply_tx, + }) + .await + .map_err(internal)?; + reply_rx.await.map_err(internal)?.map_err(internal)?; + Ok(StatusCode::NO_CONTENT) +} + +fn internal(e: E) -> (StatusCode, String) { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) +} diff --git a/breadarrd/src/api/routes/mod.rs b/breadarrd/src/api/routes/mod.rs new file mode 100644 index 0000000..2ea720c --- /dev/null +++ b/breadarrd/src/api/routes/mod.rs @@ -0,0 +1,9 @@ +pub mod calendar; +pub mod health; +pub mod library_health; +pub mod media; +pub mod quality_profiles; +pub mod releases; +pub mod review; +pub mod search; +pub mod stuck; diff --git a/breadarrd/src/api/routes/quality_profiles.rs b/breadarrd/src/api/routes/quality_profiles.rs new file mode 100644 index 0000000..0387a7b --- /dev/null +++ b/breadarrd/src/api/routes/quality_profiles.rs @@ -0,0 +1,87 @@ +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::Json; +use breadarr_shared::dto::{QualityProfileSummary, UpdateQualityProfileWeightsRequest, WeightsDto}; + +use crate::api::AppState; +use crate::scoring::{ProfileKind, QualityProfile}; + +fn to_dto(weights: &crate::scoring::profile::Weights) -> WeightsDto { + WeightsDto { + seeder: weights.seeder, + resolution_tier: weights.resolution_tier, + source_tier: weights.source_tier, + codec_tier: weights.codec_tier, + bit_depth: weights.bit_depth, + container: weights.container, + group_allowlist: weights.group_allowlist, + repack: weights.repack, + hdr: weights.hdr, + } +} + +/// Lists every quality profile with its currently-effective weights +/// (built-in defaults plus any stored override already applied) — never +/// the raw, possibly-partial stored JSON, so a client always has a +/// complete, concrete set of numbers to show and re-submit. +pub async fn list( + State(state): State, +) -> Result>, (StatusCode, String)> { + let conn = state.conn.lock().await; + let mut stmt = conn + .prepare("SELECT id, name, kind, weights FROM quality_profile ORDER BY id") + .map_err(internal)?; + let rows: Vec<(i64, String, String, String)> = stmt + .query_map([], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + }) + .map_err(internal)? + .collect::>() + .map_err(internal)?; + + let profiles = rows + .into_iter() + .map(|(id, name, kind, weights_json)| { + let profile_kind = if kind == "movie" { + ProfileKind::Movie + } else { + ProfileKind::Tv + }; + let resolved = QualityProfile::with_weights_override(profile_kind, &weights_json); + QualityProfileSummary { + id, + name, + kind, + weights: to_dto(&resolved.weights), + } + }) + .collect(); + Ok(Json(profiles)) +} + +/// Overwrites a profile's stored `weights` column with exactly the +/// submitted values (a full replacement, not a partial patch) — the TUI +/// always submits the complete resolved set it displayed, so there's no +/// ambiguity about what "the rest stays as it was" would even mean here. +pub async fn update_weights( + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result { + let weights_json = serde_json::to_string(&req.weights).map_err(internal)?; + let conn = state.conn.lock().await; + let updated = conn + .execute( + "UPDATE quality_profile SET weights = ?1 WHERE id = ?2", + rusqlite::params![weights_json, id], + ) + .map_err(internal)?; + if updated == 0 { + return Err((StatusCode::NOT_FOUND, "quality profile not found".into())); + } + Ok(StatusCode::NO_CONTENT) +} + +fn internal(e: E) -> (StatusCode, String) { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) +} diff --git a/breadarrd/src/api/routes/releases.rs b/breadarrd/src/api/routes/releases.rs new file mode 100644 index 0000000..2301508 --- /dev/null +++ b/breadarrd/src/api/routes/releases.rs @@ -0,0 +1,38 @@ +use axum::extract::State; +use axum::http::StatusCode; +use axum::Json; +use breadarr_shared::dto::ReleaseSummary; + +use crate::api::AppState; + +pub async fn list( + State(state): State, +) -> Result>, (StatusCode, String)> { + let conn = state.conn.lock().await; + let mut stmt = conn + .prepare( + "SELECT r.id, m.title, r.raw_title, r.score, r.status, r.grabbed_at + FROM release r JOIN media_item m ON m.id = r.media_item_id + ORDER BY r.grabbed_at DESC LIMIT 200", + ) + .map_err(internal)?; + let rows = stmt + .query_map([], |row| { + Ok(ReleaseSummary { + id: row.get(0)?, + media_title: row.get(1)?, + raw_title: row.get(2)?, + score: row.get(3)?, + status: row.get(4)?, + grabbed_at: row.get(5)?, + }) + }) + .map_err(internal)? + .collect::>>() + .map_err(internal)?; + Ok(Json(rows)) +} + +fn internal(e: E) -> (StatusCode, String) { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) +} diff --git a/breadarrd/src/api/routes/review.rs b/breadarrd/src/api/routes/review.rs new file mode 100644 index 0000000..f16fbeb --- /dev/null +++ b/breadarrd/src/api/routes/review.rs @@ -0,0 +1,108 @@ +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::Json; +use breadarr_shared::dto::ReviewQueueEntry; + +use crate::api::AppState; +use crate::scheduler; + +pub async fn list( + State(state): State, +) -> Result>, (StatusCode, String)> { + let conn = state.conn.lock().await; + let mut stmt = conn + .prepare( + "SELECT rq.id, rq.raw_release_title, m.title, rq.confidence, rq.status, rq.created_at + FROM review_queue rq LEFT JOIN media_item m ON m.id = rq.candidate_media_item_id + WHERE rq.status = 'pending' + ORDER BY rq.created_at DESC", + ) + .map_err(internal)?; + let rows = stmt + .query_map([], |row| { + Ok(ReviewQueueEntry { + id: row.get(0)?, + raw_release_title: row.get(1)?, + candidate_media_title: row.get(2)?, + confidence: row.get(3)?, + status: row.get(4)?, + created_at: row.get(5)?, + }) + }) + .map_err(internal)? + .collect::>>() + .map_err(internal)?; + Ok(Json(rows)) +} + +pub async fn approve( + State(state): State, + Path(id): Path, +) -> Result { + let Some(qbit) = &state.qbit else { + return Err(( + StatusCode::PRECONDITION_FAILED, + "qbit.base_url is not configured".into(), + )); + }; + + let prep = { + let conn = state.conn.lock().await; + scheduler::prepare_review_approval(&conn, id).map_err(internal)? + }; + let prepared = match prep { + scheduler::ApprovalPrep::Ready(p) => p, + scheduler::ApprovalPrep::NotPending => { + return Err((StatusCode::CONFLICT, "review item is not pending".into())) + } + scheduler::ApprovalPrep::MissingGrabData => { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + "review item has no stored link/source to grab".into(), + )) + } + scheduler::ApprovalPrep::CouldNotResolveEpisode => { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + "could not resolve which episode this release is".into(), + )) + } + scheduler::ApprovalPrep::NotMonitoredOrAlreadyHave => { + return Err(( + StatusCode::CONFLICT, + "episode is not monitored or is already downloaded".into(), + )) + } + }; + + let torrent_hash = scheduler::grab_prepared_approval(qbit, &state.qbit_category, &prepared) + .await + .map_err(internal)?; + + { + let conn = state.conn.lock().await; + scheduler::finalize_review_approval( + &conn, + id, + &prepared, + &state.qbit_category, + torrent_hash.as_deref(), + ) + .map_err(internal)?; + } + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn reject( + State(state): State, + Path(id): Path, +) -> Result { + let conn = state.conn.lock().await; + scheduler::reject_review(&conn, id).map_err(internal)?; + Ok(StatusCode::NO_CONTENT) +} + +fn internal(e: E) -> (StatusCode, String) { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) +} diff --git a/breadarrd/src/api/routes/search.rs b/breadarrd/src/api/routes/search.rs new file mode 100644 index 0000000..cc032e4 --- /dev/null +++ b/breadarrd/src/api/routes/search.rs @@ -0,0 +1,63 @@ +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::Json; +use breadarr_shared::dto::SearchResult; +use serde::Deserialize; + +use crate::api::AppState; + +fn default_kind() -> String { + "series".to_string() +} + +#[derive(Deserialize)] +pub struct SearchParams { + q: String, + #[serde(default = "default_kind")] + kind: String, +} + +pub async fn search( + State(state): State, + Query(params): Query, +) -> Result>, (StatusCode, String)> { + if params.kind == "movie" { + let Some(tmdb) = &state.tmdb else { + return Err(( + StatusCode::PRECONDITION_FAILED, + "tmdb.bearer_token is not configured".into(), + )); + }; + let results = tmdb + .search_movie(¶ms.q) + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))? + .into_iter() + .map(|r| SearchResult { + external_id: r.external_id, + title: r.title, + year: r.year.map(|y| y as i64), + }) + .collect(); + return Ok(Json(results)); + } + + let Some(tvdb) = &state.tvdb else { + return Err(( + StatusCode::PRECONDITION_FAILED, + "tvdb.api_key is not configured".into(), + )); + }; + let results = tvdb + .search_series(¶ms.q) + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))? + .into_iter() + .map(|r| SearchResult { + external_id: r.external_id, + title: r.name, + year: r.year.map(|y| y as i64), + }) + .collect(); + Ok(Json(results)) +} diff --git a/breadarrd/src/api/routes/stuck.rs b/breadarrd/src/api/routes/stuck.rs new file mode 100644 index 0000000..c4d6888 --- /dev/null +++ b/breadarrd/src/api/routes/stuck.rs @@ -0,0 +1,84 @@ +use axum::extract::State; +use axum::http::StatusCode; +use axum::Json; +use breadarr_shared::dto::{MaxedSearchTarget, StalledGrab, StuckReport}; + +use crate::api::AppState; + +/// Deliberately earlier than importer::mod's own `STALL_THRESHOLD_HOURS` +/// (72h) — this report exists to surface a stuck grab *before* the +/// daemon's own auto-fail kicks in, not just repeat it after the fact. +const STALLED_GRAB_HOURS: f64 = 48.0; + +/// Matches the point where the search-driven loop's exponential backoff +/// (`6h * 2^(count-1)`, capped at 168h) saturates — see `scheduler:: +/// DUE_CLAUSE`. A target still at this count with no successful grab has +/// been failing its search every single time for at least a week. +const MAXED_SEARCH_COUNT: i64 = 6; + +pub async fn stuck( + State(state): State, +) -> Result, (StatusCode, String)> { + let conn = state.conn.lock().await; + + let mut stmt = conn + .prepare( + "SELECT r.id, m.title, r.raw_title, r.grabbed_at + FROM release r JOIN media_item m ON m.id = r.media_item_id + WHERE r.status = 'grabbed' + AND (julianday('now') - julianday(r.grabbed_at)) * 24.0 > ?1 + ORDER BY r.grabbed_at ASC", + ) + .map_err(internal)?; + let stalled_grabs = stmt + .query_map([STALLED_GRAB_HOURS], |row| { + Ok(StalledGrab { + release_id: row.get(0)?, + media_title: row.get(1)?, + raw_title: row.get(2)?, + grabbed_at: row.get(3)?, + }) + }) + .map_err(internal)? + .collect::>>() + .map_err(internal)?; + + let review_queue_depth: i64 = conn + .query_row( + "SELECT count(*) FROM review_queue WHERE status = 'pending'", + [], + |row| row.get(0), + ) + .map_err(internal)?; + + let mut stmt = conn + .prepare( + "SELECT ss.media_item_id, m.title, ss.search_count, ss.last_searched_at + FROM search_state ss JOIN media_item m ON m.id = ss.media_item_id + WHERE ss.search_count >= ?1 AND ss.last_result != 'grabbed' + ORDER BY ss.search_count DESC", + ) + .map_err(internal)?; + let maxed_out_search_targets = stmt + .query_map([MAXED_SEARCH_COUNT], |row| { + Ok(MaxedSearchTarget { + media_item_id: row.get(0)?, + media_title: row.get(1)?, + search_count: row.get(2)?, + last_searched_at: row.get(3)?, + }) + }) + .map_err(internal)? + .collect::>>() + .map_err(internal)?; + + Ok(Json(StuckReport { + stalled_grabs, + review_queue_depth, + maxed_out_search_targets, + })) +} + +fn internal(e: E) -> (StatusCode, String) { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) +} diff --git a/breadarrd/src/db.rs b/breadarrd/src/db.rs new file mode 100644 index 0000000..88ff19a --- /dev/null +++ b/breadarrd/src/db.rs @@ -0,0 +1,615 @@ +use rusqlite::Connection; + +/// How many recent backups `backup_before_open` keeps around — bounded so +/// a long-running daemon (restarted routinely by systemd on crash/update) +/// doesn't slowly fill the disk with an ever-growing pile of copies. +const MAX_BACKUPS: usize = 5; + +/// Copies the database (and its WAL/SHM sidecar files, if present — WAL +/// mode means the real state can be split across all three) to a timestamped +/// backup before the daemon opens it, then prunes old backups beyond +/// `MAX_BACKUPS`. A no-op if there's no existing database yet (fresh +/// install — nothing to back up). Sonarr/Radarr back themselves up before +/// every upgrade; breadarr has no migration framework to trigger that same +/// moment, so this runs on every startup instead, which is a superset of +/// the same protection. +pub fn backup_before_open(db_path: &std::path::Path) -> anyhow::Result<()> { + if !db_path.exists() { + return Ok(()); + } + let backup_dir = db_path + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join("backups"); + std::fs::create_dir_all(&backup_dir)?; + + let stem = db_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("breadarr.db"); + // Millisecond precision (not just seconds) so two backups triggered + // within the same second — restart-crash-loop territory — still sort + // and dedupe correctly instead of the second one silently overwriting. + let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S%3fZ"); + let dest = backup_dir.join(format!("{timestamp}-{stem}")); + std::fs::copy(db_path, &dest)?; + + for sidecar_ext in ["-wal", "-shm"] { + let sidecar = std::path::PathBuf::from(format!("{}{sidecar_ext}", db_path.display())); + if sidecar.exists() { + let dest_sidecar = backup_dir.join(format!("{timestamp}-{stem}{sidecar_ext}")); + std::fs::copy(&sidecar, &dest_sidecar)?; + } + } + + prune_old_backups(&backup_dir, stem)?; + Ok(()) +} + +fn prune_old_backups(backup_dir: &std::path::Path, stem: &str) -> anyhow::Result<()> { + let mut entries: Vec<_> = std::fs::read_dir(backup_dir)? + .filter_map(|e| e.ok()) + .filter(|e| { + let name = e.file_name(); + let name = name.to_string_lossy(); + name.ends_with(stem) && !name.ends_with("-wal") && !name.ends_with("-shm") + }) + .collect(); + // Filenames are `-` with a sortable timestamp format, + // so lexical order is chronological order. + entries.sort_by_key(|e| e.file_name()); + if entries.len() > MAX_BACKUPS { + for e in &entries[..entries.len() - MAX_BACKUPS] { + let base = e.file_name().to_string_lossy().to_string(); + let _ = std::fs::remove_file(e.path()); + for sidecar_ext in ["-wal", "-shm"] { + let _ = std::fs::remove_file(backup_dir.join(format!("{base}{sidecar_ext}"))); + } + } + } + Ok(()) +} + +/// Ad-hoc migration for a column introduced after the initial schema. No +/// migration framework exists (single production deployment, `CREATE TABLE +/// IF NOT EXISTS` is the only mechanism otherwise) — idempotent by simply +/// ignoring "duplicate column name", so it's safe to call on every startup +/// against both a fresh database and one that already has the column. +fn add_column_if_missing( + conn: &Connection, + table: &str, + column: &str, + ddl: &str, +) -> anyhow::Result<()> { + let sql = format!("ALTER TABLE {table} ADD COLUMN {column} {ddl}"); + match conn.execute(&sql, []) { + Ok(_) => Ok(()), + Err(rusqlite::Error::SqliteFailure(_, Some(msg))) + if msg.contains("duplicate column name") => + { + Ok(()) + } + Err(e) => Err(e.into()), + } +} + +pub fn init(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch( + "PRAGMA journal_mode=WAL; + PRAGMA foreign_keys=ON; + PRAGMA busy_timeout=5000; + + CREATE TABLE IF NOT EXISTS quality_profile ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL CHECK (kind IN ('movie','tv')), + cutoff REAL, + weights TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS media_item ( + id INTEGER PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('movie','series')), + title TEXT NOT NULL, + year INTEGER, + tvdb_id INTEGER, + tmdb_id INTEGER, + anidb_id INTEGER, + monitored INTEGER NOT NULL DEFAULT 1, + quality_profile_id INTEGER NOT NULL REFERENCES quality_profile(id), + root_folder TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS alias ( + id INTEGER PRIMARY KEY, + media_item_id INTEGER NOT NULL REFERENCES media_item(id) ON DELETE CASCADE, + text TEXT NOT NULL, + source TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS season ( + id INTEGER PRIMARY KEY, + media_item_id INTEGER NOT NULL REFERENCES media_item(id) ON DELETE CASCADE, + season_number INTEGER NOT NULL, + monitored INTEGER NOT NULL DEFAULT 1, + UNIQUE(media_item_id, season_number) + ); + + CREATE TABLE IF NOT EXISTS episode ( + id INTEGER PRIMARY KEY, + media_item_id INTEGER NOT NULL REFERENCES media_item(id) ON DELETE CASCADE, + season_number INTEGER NOT NULL, + episode_number INTEGER NOT NULL, + absolute_number INTEGER, + title TEXT, + air_date TEXT, + monitored INTEGER NOT NULL DEFAULT 1, + has_file INTEGER NOT NULL DEFAULT 0, + UNIQUE(media_item_id, season_number, episode_number) + ); + + CREATE TABLE IF NOT EXISTS episode_file ( + id INTEGER PRIMARY KEY, + episode_id INTEGER REFERENCES episode(id) ON DELETE CASCADE, + media_item_id INTEGER REFERENCES media_item(id) ON DELETE CASCADE, + path TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + video_codec TEXT, + resolution TEXT, + audio_langs TEXT, + quality_score REAL, + subtitle_status TEXT NOT NULL DEFAULT 'none' + CHECK (subtitle_status IN ('none','not_needed','queued','done')) + ); + + CREATE TABLE IF NOT EXISTS source ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL CHECK (kind IN ('rss','scrape')), + base_url TEXT NOT NULL, + poll_interval_secs INTEGER NOT NULL DEFAULT 300, + enabled INTEGER NOT NULL DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS release ( + id INTEGER PRIMARY KEY, + media_item_id INTEGER NOT NULL REFERENCES media_item(id) ON DELETE CASCADE, + episode_id INTEGER REFERENCES episode(id) ON DELETE CASCADE, + raw_title TEXT NOT NULL, + source_id INTEGER NOT NULL REFERENCES source(id), + guid TEXT NOT NULL, + score REAL, + torrent_hash TEXT, + status TEXT NOT NULL + CHECK (status IN ('grabbed','downloading','imported','upgraded','failed')), + grabbed_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS anime_mapping ( + anidb_id INTEGER PRIMARY KEY, + tvdb_id INTEGER, + tmdb_id INTEGER, + season_offset INTEGER, + episode_offset INTEGER NOT NULL DEFAULT 0 + ); + + -- Anime *movies'* TMDB ids, separate from `anime_mapping` above: + -- one AniDB entry can map to several TMDB movie ids (rereleases, + -- split cuts), which doesn't fit `anime_mapping`'s one-row-per- + -- anidb_id shape (that shape exists for TV's season/episode-offset + -- upsert semantics, meaningless for movies) — and nothing about a + -- movie needs the anidb_id linkage anyway, only is-this-tmdb_id- + -- an-anime-movie membership. + CREATE TABLE IF NOT EXISTS anime_tmdb_movie ( + tmdb_id INTEGER PRIMARY KEY + ); + + CREATE TABLE IF NOT EXISTS review_queue ( + id INTEGER PRIMARY KEY, + raw_release_title TEXT NOT NULL, + candidate_media_item_id INTEGER REFERENCES media_item(id) ON DELETE CASCADE, + confidence REAL NOT NULL, + link TEXT, + source_id INTEGER REFERENCES source(id), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','approved','rejected')), + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS seen_guid ( + source_id INTEGER NOT NULL REFERENCES source(id) ON DELETE CASCADE, + guid TEXT NOT NULL, + seen_at TEXT NOT NULL, + PRIMARY KEY (source_id, guid) + ); + + -- One row per monitored episode/movie the search-driven loop has + -- attempted at least once. `episode_id` NULL means the row is for + -- a movie (same nullable-episode_id convention as episode_file) — + -- a plain UNIQUE(media_item_id, episode_id) can't express that, + -- since SQLite treats NULLs as distinct, so two partial unique + -- indexes stand in for it instead. + CREATE TABLE IF NOT EXISTS search_state ( + id INTEGER PRIMARY KEY, + media_item_id INTEGER NOT NULL REFERENCES media_item(id) ON DELETE CASCADE, + episode_id INTEGER REFERENCES episode(id) ON DELETE CASCADE, + last_searched_at TEXT NOT NULL, + search_count INTEGER NOT NULL DEFAULT 1, + last_result TEXT NOT NULL + CHECK (last_result IN ('grabbed','no_results','no_viable','error')) + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_search_state_episode + ON search_state(episode_id) WHERE episode_id IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS idx_search_state_movie + ON search_state(media_item_id) WHERE episode_id IS NULL; + + -- Same shape as `search_state`, but for the separate upgrade-search + -- cadence: already-imported episodes/movies periodically re-checked + -- for a better release. Kept as its own table rather than reusing + -- `search_state` so the two cadences (missing-content vs. upgrade) + -- never interfere with each other's due-ness clock. + CREATE TABLE IF NOT EXISTS upgrade_state ( + id INTEGER PRIMARY KEY, + media_item_id INTEGER NOT NULL REFERENCES media_item(id) ON DELETE CASCADE, + episode_id INTEGER REFERENCES episode(id) ON DELETE CASCADE, + last_checked_at TEXT NOT NULL, + check_count INTEGER NOT NULL DEFAULT 1, + last_result TEXT NOT NULL + CHECK (last_result IN ('grabbed','no_results','no_viable','error')) + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_upgrade_state_episode + ON upgrade_state(episode_id) WHERE episode_id IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS idx_upgrade_state_movie + ON upgrade_state(media_item_id) WHERE episode_id IS NULL; + + -- A queryable decision log (grabbed/review_queued/imported/failed), + -- separate from `release`'s current-state columns — added after + -- a night of reconstructing why-did-it-do-that purely from + -- journal logs. `media_item_id` has no FK so a row survives even + -- after its media_item is deleted (the point is historical + -- record, not live referential integrity). + CREATE TABLE IF NOT EXISTS event_history ( + id INTEGER PRIMARY KEY, + media_item_id INTEGER, + episode_id INTEGER, + event_type TEXT NOT NULL + CHECK (event_type IN ('grabbed','review_queued','imported','failed')), + detail TEXT NOT NULL, + occurred_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_event_history_media_item + ON event_history(media_item_id, occurred_at); + + -- Placeholder profiles until Phase 6 builds real quality-scoring + -- weights; media_item.quality_profile_id needs something to + -- reference in the meantime. + INSERT OR IGNORE INTO quality_profile (id, name, kind, weights) + VALUES (1, 'Default TV', 'tv', '{}'); + INSERT OR IGNORE INTO quality_profile (id, name, kind, weights) + VALUES (2, 'Default Movie', 'movie', '{}'); + + -- One row per `episode_file`, ffprobe's ground-truth read of what's + -- actually on disk — a deliberate companion table rather than more + -- columns on `episode_file` (which already carries three vestigial, + -- never-populated columns from an earlier attempt at this: don't + -- compound that mistake). `probe_size_bytes`/`probe_mtime` are a + -- freshness guard, not just metadata: `reconcile_missing_files` + -- repairs a stale `episode_file.path` onto the *same row id* when + -- a file is renamed or transcoded in place (e.g. Tdarr converting + -- codec/container), so a probe keyed only to that row id would + -- silently describe a file that no longer exists unless something + -- notices the underlying bytes changed. + CREATE TABLE IF NOT EXISTS media_file_probe ( + episode_file_id INTEGER PRIMARY KEY + REFERENCES episode_file(id) ON DELETE CASCADE, + probed_at TEXT NOT NULL, + probe_size_bytes INTEGER NOT NULL, + probe_mtime INTEGER NOT NULL, + + duration_secs REAL, + container_bitrate INTEGER, + video_codec TEXT, + width INTEGER, + height INTEGER, + video_bitrate INTEGER, + audio_codecs TEXT, + audio_langs TEXT, + default_audio_lang TEXT, + subtitle_langs TEXT, + + corruption_status TEXT NOT NULL DEFAULT 'unknown' + CHECK (corruption_status IN + ('unknown','probe_ok','probe_failed','decode_ok','decode_failed')), + corruption_checked_at TEXT, + + flag_under_quality INTEGER NOT NULL DEFAULT 0, + flag_no_subtitles INTEGER NOT NULL DEFAULT 0, + flag_no_english_audio INTEGER NOT NULL DEFAULT 0, + flag_non_english_default_audio INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_probe_flags + ON media_file_probe(flag_under_quality, flag_no_subtitles, + flag_no_english_audio, flag_non_english_default_audio); + CREATE INDEX IF NOT EXISTS idx_probe_corruption + ON media_file_probe(corruption_status); + + -- Permanent audit trail of every torrent breadarr has ever handed + -- to qBittorrent, independent of whatever later happens to the + -- `release` row it's associated with (which can be deleted, or + -- flipped between grabbed/failed/imported/upgraded over time). No + -- foreign keys — same reasoning as `event_history`: the point is a + -- durable historical record, not live referential integrity, so a + -- row must survive its source/media_item being deleted. + CREATE TABLE IF NOT EXISTS torrent_fetch ( + id INTEGER PRIMARY KEY, + torrent_hash TEXT, + name TEXT NOT NULL, + size_bytes INTEGER, + category TEXT, + source_id INTEGER, + media_item_id INTEGER, + episode_id INTEGER, + status TEXT NOT NULL, + fetched_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_torrent_fetch_hash ON torrent_fetch(torrent_hash); + CREATE INDEX IF NOT EXISTS idx_torrent_fetch_fetched_at ON torrent_fetch(fetched_at);", + )?; + + // Progress watermark for stalled-download detection (added after the + // initial `release` schema — see `add_column_if_missing`). + add_column_if_missing(conn, "release", "last_seen_progress", "REAL")?; + add_column_if_missing(conn, "release", "last_progress_at", "TEXT")?; + // Counts consecutive `import_one` failures for a completed torrent — + // without this, a persistent import error (a bad path remap, an + // unreadable file) retried forever every cycle with no escalation, + // permanently blocking the episode/movie from being re-searched via a + // different release (`status` stays `grabbed`) while never actually + // succeeding either. + add_column_if_missing( + conn, + "release", + "import_error_count", + "INTEGER NOT NULL DEFAULT 0", + )?; + // Set only for season-pack grabs (`episode_id` stays NULL for these, + // same convention as movies — but unlike a movie, one media_item can + // have many *different* season packs, so episode_id-is-NULL alone + // can't disambiguate "season 1 pack" from "season 2 pack" the way it + // disambiguates "this movie" from nothing else. This column is that + // disambiguator, used to scope existing-grab/upgrade comparisons and + // in-flight checks to the right season. + add_column_if_missing(conn, "release", "season_number", "INTEGER")?; + + // Extra per-file metadata beyond the original probe columns — kept as + // its own batch of `add_column_if_missing` calls (rather than folded + // into the original `CREATE TABLE`) since that table already exists on + // deployed databases. None of this feeds any decision yet; the point is + // to have it already sitting in the DB, ready for whatever uses it + // later, rather than needing a second full-library re-probe when that + // day comes. + add_column_if_missing(conn, "media_file_probe", "frame_rate", "REAL")?; + add_column_if_missing( + conn, + "media_file_probe", + "default_audio_channels", + "INTEGER", + )?; + // Best-effort classification (see MediaProbe::is_hdr) computed at + // probe time from the raw color_transfer tag, which is also kept + // verbatim below rather than only storing the reduced boolean. + add_column_if_missing( + conn, + "media_file_probe", + "hdr", + "INTEGER NOT NULL DEFAULT 0", + )?; + add_column_if_missing(conn, "media_file_probe", "color_transfer", "TEXT")?; + add_column_if_missing(conn, "media_file_probe", "container_format", "TEXT")?; + // The complete ffprobe JSON response, verbatim — the escape hatch for + // anything not already worth its own column (chapters, encoder tag, + // less-common stream metadata). See `ffprobe::MediaProbe::raw_json`. + add_column_if_missing(conn, "media_file_probe", "raw_ffprobe_json", "TEXT")?; + + Ok(()) +} + +/// Appends one row to `event_history`. `event_type` must be one of the +/// values the table's `CHECK` constraint allows — a bad value surfaces +/// immediately as a `rusqlite::Error`, not a silent no-op. +pub fn record_event( + conn: &Connection, + media_item_id: i64, + episode_id: Option, + event_type: &str, + detail: &str, +) -> anyhow::Result<()> { + conn.execute( + "INSERT INTO event_history (media_item_id, episode_id, event_type, detail, occurred_at) + VALUES (?1, ?2, ?3, ?4, datetime('now'))", + rusqlite::params![media_item_id, episode_id, event_type, detail], + )?; + Ok(()) +} + +/// Appends one row to `torrent_fetch` — the permanent audit log, distinct +/// from `release` (whose status/hash can change or which can be deleted +/// alongside its media_item). Called once per actual qBittorrent add, +/// regardless of what happens to the grab afterward. +#[allow(clippy::too_many_arguments)] +pub fn record_torrent_fetch( + conn: &Connection, + torrent_hash: Option<&str>, + name: &str, + size_bytes: Option, + category: &str, + source_id: i64, + media_item_id: i64, + episode_id: Option, + status: &str, +) -> anyhow::Result<()> { + conn.execute( + "INSERT INTO torrent_fetch (torrent_hash, name, size_bytes, category, source_id, media_item_id, episode_id, status, fetched_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, datetime('now'))", + rusqlite::params![torrent_hash, name, size_bytes, category, source_id, media_item_id, episode_id, status], + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn init_is_idempotent() { + let conn = Connection::open_in_memory().unwrap(); + init(&conn).unwrap(); + init(&conn).unwrap(); + + let table_count: i64 = conn + .query_row( + "SELECT count(*) FROM sqlite_master WHERE type='table'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(table_count, 17); + } + + #[test] + fn record_event_inserts_a_queryable_row() { + let conn = Connection::open_in_memory().unwrap(); + init(&conn).unwrap(); + + record_event(&conn, 42, Some(7), "grabbed", "score=8.5").unwrap(); + + let (media_item_id, episode_id, event_type, detail): (i64, Option, String, String) = + conn.query_row( + "SELECT media_item_id, episode_id, event_type, detail FROM event_history", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + ) + .unwrap(); + assert_eq!(media_item_id, 42); + assert_eq!(episode_id, Some(7)); + assert_eq!(event_type, "grabbed"); + assert_eq!(detail, "score=8.5"); + } + + #[test] + fn record_event_rejects_an_unknown_event_type() { + let conn = Connection::open_in_memory().unwrap(); + init(&conn).unwrap(); + assert!(record_event(&conn, 1, None, "not_a_real_type", "x").is_err()); + } + + #[test] + fn record_torrent_fetch_inserts_a_queryable_row() { + let conn = Connection::open_in_memory().unwrap(); + init(&conn).unwrap(); + + record_torrent_fetch( + &conn, + Some("deadbeef"), + "Some.Release.Title", + Some(1_500_000_000), + "breadarr", + 1, + 42, + Some(7), + "grabbed", + ) + .unwrap(); + + let (hash, name, size, status): (Option, String, Option, String) = conn + .query_row( + "SELECT torrent_hash, name, size_bytes, status FROM torrent_fetch", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + ) + .unwrap(); + assert_eq!(hash.as_deref(), Some("deadbeef")); + assert_eq!(name, "Some.Release.Title"); + assert_eq!(size, Some(1_500_000_000)); + assert_eq!(status, "grabbed"); + } + + #[test] + fn record_torrent_fetch_allows_a_null_hash_and_size() { + let conn = Connection::open_in_memory().unwrap(); + init(&conn).unwrap(); + + record_torrent_fetch( + &conn, + None, + "Uncorrelated Add", + None, + "breadarr", + 1, + 42, + None, + "failed", + ) + .unwrap(); + + let count: i64 = conn + .query_row("SELECT count(*) FROM torrent_fetch", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn backup_before_open_is_a_noop_when_no_database_exists_yet() { + let dir = std::env::temp_dir().join(format!("breadarr-backup-noop-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let db_path = dir.join("breadarr.db"); + + backup_before_open(&db_path).unwrap(); + let backup_dir = dir.join("backups"); + assert!( + !backup_dir.exists(), + "no backup dir should be created for a fresh install" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn backup_before_open_copies_an_existing_database() { + let dir = std::env::temp_dir().join(format!("breadarr-backup-copy-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let db_path = dir.join("breadarr.db"); + std::fs::write(&db_path, b"fake sqlite data").unwrap(); + + backup_before_open(&db_path).unwrap(); + + let backup_dir = dir.join("backups"); + let backups: Vec<_> = std::fs::read_dir(&backup_dir).unwrap().collect(); + assert_eq!(backups.len(), 1, "expected exactly one backup file"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn backup_before_open_prunes_beyond_max_backups() { + let dir = + std::env::temp_dir().join(format!("breadarr-backup-prune-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let db_path = dir.join("breadarr.db"); + std::fs::write(&db_path, b"fake sqlite data").unwrap(); + + // One more than MAX_BACKUPS, sleeping a few ms between each so the + // millisecond-resolution timestamp in the filename is guaranteed to + // differ and sort correctly. + for _ in 0..(MAX_BACKUPS + 2) { + backup_before_open(&db_path).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + let backup_dir = dir.join("backups"); + let backups: Vec<_> = std::fs::read_dir(&backup_dir).unwrap().collect(); + assert_eq!(backups.len(), MAX_BACKUPS); + + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/breadarrd/src/importer/ffprobe.rs b/breadarrd/src/importer/ffprobe.rs new file mode 100644 index 0000000..5f576e5 --- /dev/null +++ b/breadarrd/src/importer/ffprobe.rs @@ -0,0 +1,379 @@ +use std::path::Path; +use std::process::Command; + +use anyhow::{bail, Context, Result}; + +#[derive(Debug, Clone, PartialEq)] +pub struct AudioStream { + pub codec: Option, + pub language: Option, + pub is_default: bool, + pub channels: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SubtitleStream { + pub language: Option, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct MediaProbe { + pub duration_secs: Option, + pub container_bitrate: Option, + /// ffprobe's own long-form container name (e.g. "Matroska / WebM") — + /// distinct from the file extension, which can lie or just be absent. + pub container_format: Option, + pub video_codec: Option, + pub width: Option, + pub height: Option, + pub video_bitrate: Option, + pub frame_rate: Option, + /// The primary video stream's `color_transfer` tag as ffprobe reports + /// it (e.g. "smpte2084", "arib-std-b67", "bt709") — kept as the raw + /// string rather than pre-reduced to a bool so a future caller isn't + /// stuck with just today's idea of what counts as "HDR" (Dolby Vision + /// profiles, for instance, don't all surface the same way here). + pub color_transfer: Option, + pub audio: Vec, + pub subtitles: Vec, + /// The complete ffprobe JSON response, kept verbatim. Every field + /// above is a deliberately-narrow, purpose-built read of this same + /// payload; this is the escape hatch — future functionality that needs + /// something not already surfaced as its own column (chapters, extra + /// stream tags, encoder info, etc.) can mine it here without requiring + /// a re-probe of the whole library. + pub raw_json: String, +} + +impl MediaProbe { + /// Best-effort HDR classification from `color_transfer` — PQ + /// (smpte2084, the common HDR10/HDR10+/Dolby-Vision-base-layer + /// transfer function) or HLG (arib-std-b67). Not exhaustive by design; + /// see `color_transfer`'s own doc comment for why the raw value is kept + /// around too. + pub fn is_hdr(&self) -> bool { + matches!( + self.color_transfer.as_deref(), + Some("smpte2084") | Some("arib-std-b67") + ) + } +} + +fn is_english(lang: Option<&str>) -> bool { + matches!(lang, Some("eng") | Some("en")) +} + +impl MediaProbe { + pub fn has_english_audio(&self) -> bool { + self.audio.iter().any(|a| is_english(a.language.as_deref())) + } + + /// True when the default-disposition audio track (if any) is not + /// English — mirrors `mkv::default_track_is_english_or_unset`'s "unset + /// default isn't a problem" behavior, since the codebase's post-download + /// remux fix already treats those two cases identically. + pub fn default_audio_is_non_english(&self) -> bool { + match self.audio.iter().find(|a| a.is_default) { + Some(a) => !is_english(a.language.as_deref()), + None => false, + } + } +} + +/// One `ffprobe` call gets container + every stream's codec/resolution/ +/// bitrate/language/default-disposition in a single JSON payload — no need +/// for separate audio/video/subtitle passes. +pub fn probe(path: &Path) -> Result { + let output = Command::new("ffprobe") + .args([ + "-v", + "quiet", + "-print_format", + "json", + "-show_format", + "-show_streams", + ]) + .arg(path) + .output() + .context("failed to run ffprobe")?; + + if !output.status.success() { + bail!( + "ffprobe failed for {}: {}", + path.display(), + String::from_utf8_lossy(&output.stderr) + ); + } + + let raw_json = String::from_utf8_lossy(&output.stdout).into_owned(); + let json: serde_json::Value = + serde_json::from_slice(&output.stdout).context("ffprobe output was not valid JSON")?; + + let format = &json["format"]; + let duration_secs = format["duration"] + .as_str() + .and_then(|s| s.parse::().ok()); + let container_bitrate = format["bit_rate"] + .as_str() + .and_then(|s| s.parse::().ok()); + let container_format = format["format_long_name"].as_str().map(str::to_string); + + let streams = json["streams"].as_array().cloned().unwrap_or_default(); + + let mut probe = MediaProbe { + duration_secs, + container_bitrate, + container_format, + raw_json, + ..Default::default() + }; + + for stream in &streams { + let codec_type = stream["codec_type"].as_str().unwrap_or(""); + let language = stream["tags"]["language"] + .as_str() + .or_else(|| stream["tags"]["LANGUAGE"].as_str()) + .map(str::to_string); + match codec_type { + "video" if probe.video_codec.is_none() => { + // First video stream only — a second "video" stream in a + // real-world file is almost always an embedded cover-art + // thumbnail, not a second picture track. + probe.video_codec = stream["codec_name"].as_str().map(str::to_string); + probe.width = stream["width"].as_i64(); + probe.height = stream["height"].as_i64(); + probe.video_bitrate = stream["bit_rate"] + .as_str() + .and_then(|s| s.parse::().ok()); + probe.frame_rate = stream["avg_frame_rate"] + .as_str() + .and_then(parse_frame_rate_fraction) + .or_else(|| { + stream["r_frame_rate"] + .as_str() + .and_then(parse_frame_rate_fraction) + }); + probe.color_transfer = stream["color_transfer"].as_str().map(str::to_string); + } + "audio" => { + probe.audio.push(AudioStream { + codec: stream["codec_name"].as_str().map(str::to_string), + language, + is_default: stream["disposition"]["default"].as_i64() == Some(1), + channels: stream["channels"].as_i64(), + }); + } + "subtitle" => { + probe.subtitles.push(SubtitleStream { language }); + } + _ => {} + } + } + + Ok(probe) +} + +/// ffprobe reports frame rate as a "num/den" fraction string (e.g. +/// "24000/1001" for 23.976fps) rather than a plain number. `0/0` means +/// "unknown" (common for e.g. still-image or data streams that don't +/// really have one), not a real rate. +fn parse_frame_rate_fraction(s: &str) -> Option { + let (num, den) = s.split_once('/')?; + let num: f64 = num.parse().ok()?; + let den: f64 = den.parse().ok()?; + if den == 0.0 { + return None; + } + Some(num / den) +} + +/// A cheap header-level probe succeeding only proves the container is +/// parseable — it doesn't catch a truncated file or mid-stream bit-rot, +/// which requires actually decoding every frame. `ffmpeg -xerror` does +/// that: any decode error either exits non-zero or writes to stderr. This +/// is CPU-bound and can take minutes per file (it reads and decodes the +/// whole thing), so it's deliberately a separate, opt-in pass rather than +/// part of every routine scan — see `verify-library` in `main.rs`. +pub enum DecodeCheck { + Ok, + Corrupt(String), +} + +pub fn verify_decodable(path: &Path) -> Result { + let output = Command::new("ffmpeg") + .args(["-v", "error", "-xerror", "-i"]) + .arg(path) + .args(["-f", "null", "-"]) + .output() + .context("failed to run ffmpeg for decode verification")?; + + if output.status.success() && output.stderr.is_empty() { + Ok(DecodeCheck::Ok) + } else { + Ok(DecodeCheck::Corrupt( + String::from_utf8_lossy(&output.stderr).into_owned(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn has_english_audio_true_when_any_track_is_english() { + let probe = MediaProbe { + audio: vec![ + AudioStream { + codec: None, + language: Some("jpn".to_string()), + is_default: true, + channels: None, + }, + AudioStream { + codec: None, + language: Some("eng".to_string()), + is_default: false, + channels: None, + }, + ], + ..Default::default() + }; + assert!(probe.has_english_audio()); + } + + #[test] + fn has_english_audio_false_with_no_tracks() { + assert!(!MediaProbe::default().has_english_audio()); + } + + #[test] + fn default_audio_is_non_english_true_when_default_track_is_not_english() { + let probe = MediaProbe { + audio: vec![AudioStream { + codec: None, + language: Some("ita".to_string()), + is_default: true, + channels: None, + }], + ..Default::default() + }; + assert!(probe.default_audio_is_non_english()); + } + + #[test] + fn default_audio_is_non_english_false_when_no_default_is_set() { + // Mirrors mkv::default_track_is_english_or_unset: an unset default + // isn't treated as a problem. + let probe = MediaProbe { + audio: vec![AudioStream { + codec: None, + language: Some("ita".to_string()), + is_default: false, + channels: None, + }], + ..Default::default() + }; + assert!(!probe.default_audio_is_non_english()); + } + + #[test] + fn default_audio_is_non_english_false_when_default_is_english() { + let probe = MediaProbe { + audio: vec![AudioStream { + codec: None, + language: Some("eng".to_string()), + is_default: true, + channels: None, + }], + ..Default::default() + }; + assert!(!probe.default_audio_is_non_english()); + } + + #[test] + fn probe_fails_gracefully_for_a_nonexistent_file() { + let result = probe(Path::new("/nonexistent/path/to/nothing.mkv")); + assert!(result.is_err()); + } + + /// Generates a tiny real video via ffmpeg's `lavfi` synthetic source — + /// validates the actual JSON field extraction (width/height/codec/ + /// duration/audio language+default) against genuine ffprobe output, + /// not just hand-built `MediaProbe` fixtures. + fn generate_test_clip(dir: &Path, width: u32, height: u32) -> std::path::PathBuf { + let path = dir.join("clip.mkv"); + let status = Command::new("ffmpeg") + .args(["-y", "-f", "lavfi", "-i"]) + .arg(format!("testsrc=size={width}x{height}:duration=1:rate=1")) + .args(["-f", "lavfi", "-i", "sine=frequency=1000:duration=1"]) + .args(["-metadata:s:a:0", "language=eng"]) + .args(["-c:v", "libx264", "-c:a", "aac"]) + .arg(&path) + .output() + .expect("failed to run ffmpeg to generate a test clip"); + assert!( + status.status.success(), + "ffmpeg failed to generate test clip: {}", + String::from_utf8_lossy(&status.stderr) + ); + path + } + + #[test] + fn probe_extracts_real_resolution_and_audio_language_from_a_generated_clip() { + let dir = + std::env::temp_dir().join(format!("breadarr-ffprobe-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let clip = generate_test_clip(&dir, 640, 360); + + let probe = probe(&clip).unwrap(); + assert_eq!(probe.width, Some(640)); + assert_eq!(probe.height, Some(360)); + assert!(probe.video_codec.is_some()); + assert!(probe.duration_secs.unwrap_or(0.0) > 0.0); + assert!(probe.has_english_audio()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn probe_extracts_extra_metadata_from_a_generated_clip() { + let dir = std::env::temp_dir().join(format!( + "breadarr-ffprobe-extra-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + // Generated at rate=1 (see generate_test_clip), so frame_rate + // should come back at (or very near) 1.0. + let clip = generate_test_clip(&dir, 640, 360); + + let probe = probe(&clip).unwrap(); + assert!( + probe.frame_rate.is_some_and(|r| (r - 1.0).abs() < 0.1), + "frame_rate was {:?}", + probe.frame_rate + ); + assert!( + probe + .container_format + .as_deref() + .is_some_and(|f| !f.is_empty()), + "container_format should be populated" + ); + assert!( + probe.audio.first().is_some_and(|a| a.channels.is_some()), + "audio channel count should be populated" + ); + // A real generated clip's default color_transfer is unlikely to be + // an HDR transfer function — this is really asserting `is_hdr()` + // doesn't spuriously fire on ordinary SDR content. + assert!(!probe.is_hdr()); + // The raw payload is kept verbatim and should parse as JSON on its + // own — a caller mining it later needs that to actually be true. + assert!(!probe.raw_json.is_empty()); + assert!(serde_json::from_str::(&probe.raw_json).is_ok()); + + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/breadarrd/src/importer/mkv.rs b/breadarrd/src/importer/mkv.rs new file mode 100644 index 0000000..003b18f --- /dev/null +++ b/breadarrd/src/importer/mkv.rs @@ -0,0 +1,94 @@ +use std::path::Path; +use std::process::Command; + +use anyhow::{bail, Context, Result}; + +#[derive(Debug, Clone, PartialEq)] +pub struct AudioTrack { + pub id: u32, + pub language: Option, + pub is_default: bool, +} + +/// Runs `mkvmerge -J` (JSON track info) — mkvtoolnix's own structured-output +/// mode, so track inspection doesn't depend on parsing human-readable text. +pub fn inspect_audio_tracks(path: &Path) -> Result> { + let output = Command::new("mkvmerge") + .arg("-J") + .arg(path) + .output() + .context("failed to run mkvmerge -J")?; + + if !output.status.success() { + bail!( + "mkvmerge -J failed for {}: {}", + path.display(), + String::from_utf8_lossy(&output.stderr) + ); + } + + let json: serde_json::Value = + serde_json::from_slice(&output.stdout).context("mkvmerge -J output was not valid JSON")?; + + let tracks = json["tracks"] + .as_array() + .context("mkvmerge -J output had no tracks array")?; + + Ok(tracks + .iter() + .filter(|t| t["type"] == "audio") + .map(|t| AudioTrack { + id: t["id"].as_u64().unwrap_or(0) as u32, + language: t["properties"]["language"].as_str().map(str::to_string), + is_default: t["properties"]["default_track"].as_bool().unwrap_or(false), + }) + .collect()) +} + +fn is_english(lang: Option<&str>) -> bool { + matches!(lang, Some("eng") | Some("en")) +} + +pub fn has_english_track(tracks: &[AudioTrack]) -> bool { + tracks.iter().any(|t| is_english(t.language.as_deref())) +} + +/// True if there's no fix needed: either the default track is already +/// English, or there's no explicit default at all (mkvmerge/players +/// typically fall back to the first audio track, and single-track files +/// have nothing to reorder). +pub fn default_track_is_english_or_unset(tracks: &[AudioTrack]) -> bool { + match tracks.iter().find(|t| t.is_default) { + Some(t) => is_english(t.language.as_deref()), + None => true, + } +} + +/// Re-flags the English track as default (and all others as non-default) +/// without re-encoding — fixes the common "Italian track 1, English track +/// 2" pattern. Returns an error if there's no English track to promote. +pub fn remux_english_default(input: &Path, output: &Path, tracks: &[AudioTrack]) -> Result<()> { + let english_id = tracks + .iter() + .find(|t| is_english(t.language.as_deref())) + .map(|t| t.id) + .context("no English audio track to promote")?; + + let mut cmd = Command::new("mkvmerge"); + cmd.arg("-o").arg(output); + for track in tracks { + let flag = if track.id == english_id { "yes" } else { "no" }; + cmd.arg("--default-track-flag") + .arg(format!("{}:{flag}", track.id)); + } + cmd.arg(input); + + let output_result = cmd.output().context("failed to run mkvmerge remux")?; + if !output_result.status.success() { + bail!( + "mkvmerge remux failed: {}", + String::from_utf8_lossy(&output_result.stderr) + ); + } + Ok(()) +} diff --git a/breadarrd/src/importer/mod.rs b/breadarrd/src/importer/mod.rs new file mode 100644 index 0000000..fd230a8 --- /dev/null +++ b/breadarrd/src/importer/mod.rs @@ -0,0 +1,3651 @@ +pub mod ffprobe; +pub mod mkv; + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use rusqlite::{params, Connection, OptionalExtension}; + +use crate::jellyfin::JellyfinClient; +use crate::qbit::QbitClient; + +pub(crate) const VIDEO_EXTS: &[&str] = &["mkv", "mp4", "avi", "mov"]; + +/// Height/width floor for `flag_under_quality`, matching the 1080p bar +/// already established by the scoring gate's own "reject a sub-1080p +/// release when a better alternative exists" rule (`scoring/gate.rs`), +/// applied here to the actual decoded file rather than a claimed +/// title-text resolution. Not configurable, unlike `QualityProfile`'s +/// per-axis weights (see `QualityProfile::with_weights_override`): this is +/// a post-import ground-truth sanity floor, not a scoring preference, so it +/// stays a constant rather than a config knob. +/// +/// **Both** width and height must be below their own floor before a file +/// is flagged — height alone isn't a valid "1080p or not" test, since a +/// wider-than-16:9 master (2.00:1 is common for prestige/streaming shows; +/// e.g. Apple TV+'s "For All Mankind" ships at 1920x960) legitimately has +/// full 1920px width with height well under 1080 purely from the aspect +/// ratio, not from being a worse encode. Verified live: 49 real episodes +/// at 1920x960 were false-positive-flagged under a height-only check +/// before this was caught. Width is the aspect-ratio-invariant half of the +/// pair, so requiring *both* dimensions to read low is what actually +/// distinguishes "genuinely low resolution" from "correctly cropped." +const UNDER_QUALITY_HEIGHT: i64 = 1080; +const UNDER_QUALITY_WIDTH: i64 = 1920; + +enum PendingGrab { + Episode { + release_id: i64, + episode_id: i64, + media_item_id: i64, + torrent_hash: String, + series_title: String, + season_number: u32, + episode_number: u32, + episode_title: Option, + root_folder: String, + }, + Movie { + release_id: i64, + media_item_id: i64, + torrent_hash: String, + title: String, + year: Option, + root_folder: String, + }, + /// A season-pack/batch torrent: one release row, but potentially many + /// files inside mapping to many episodes — see `import_season_pack`. + /// `episode_id` stays absent on the release row (`season_number` + /// disambiguates instead, same convention as everywhere else this + /// column is used). + SeasonPack { + release_id: i64, + media_item_id: i64, + torrent_hash: String, + series_title: String, + season_number: u32, + root_folder: String, + }, +} + +impl PendingGrab { + fn release_id(&self) -> i64 { + match self { + PendingGrab::Episode { release_id, .. } + | PendingGrab::Movie { release_id, .. } + | PendingGrab::SeasonPack { release_id, .. } => *release_id, + } + } + + fn media_item_id(&self) -> i64 { + match self { + PendingGrab::Episode { media_item_id, .. } + | PendingGrab::Movie { media_item_id, .. } + | PendingGrab::SeasonPack { media_item_id, .. } => *media_item_id, + } + } + + fn torrent_hash(&self) -> &str { + match self { + PendingGrab::Episode { torrent_hash, .. } + | PendingGrab::Movie { torrent_hash, .. } + | PendingGrab::SeasonPack { torrent_hash, .. } => torrent_hash, + } + } + + fn episode_id(&self) -> Option { + match self { + PendingGrab::Episode { episode_id, .. } => Some(*episode_id), + PendingGrab::Movie { .. } | PendingGrab::SeasonPack { .. } => None, + } + } + + fn root_folder(&self) -> &str { + match self { + PendingGrab::Episode { root_folder, .. } + | PendingGrab::Movie { root_folder, .. } + | PendingGrab::SeasonPack { root_folder, .. } => root_folder, + } + } +} + +/// Three separate queries (rather than one `LEFT JOIN episode`) because a +/// movie/season-pack release's `episode_id` is NULL, which would otherwise +/// force every episode-only column to be handled as `Option` for no +/// benefit — matches the movie/episode split already used elsewhere +/// (`process_item`, `enumerate_search_targets`). +fn fetch_pending_grabs(conn: &Connection) -> Result> { + let mut out = Vec::new(); + + let mut ep_stmt = conn.prepare( + "SELECT r.id, r.episode_id, r.media_item_id, r.torrent_hash, m.title, e.season_number, e.episode_number, e.title, m.root_folder + FROM release r + JOIN episode e ON e.id = r.episode_id + JOIN media_item m ON m.id = r.media_item_id + WHERE r.status = 'grabbed' AND r.torrent_hash IS NOT NULL", + )?; + let ep_rows = ep_stmt.query_map([], |row| { + Ok(PendingGrab::Episode { + release_id: row.get(0)?, + episode_id: row.get(1)?, + media_item_id: row.get(2)?, + torrent_hash: row.get(3)?, + series_title: row.get(4)?, + season_number: row.get(5)?, + episode_number: row.get(6)?, + episode_title: row.get(7)?, + root_folder: row.get(8)?, + }) + })?; + out.extend(ep_rows.collect::>>()?); + + let mut movie_stmt = conn.prepare( + "SELECT r.id, r.media_item_id, r.torrent_hash, m.title, m.year, m.root_folder + FROM release r + JOIN media_item m ON m.id = r.media_item_id + WHERE r.status = 'grabbed' AND r.torrent_hash IS NOT NULL AND r.episode_id IS NULL AND r.season_number IS NULL", + )?; + let movie_rows = movie_stmt.query_map([], |row| { + Ok(PendingGrab::Movie { + release_id: row.get(0)?, + media_item_id: row.get(1)?, + torrent_hash: row.get(2)?, + title: row.get(3)?, + year: row.get(4)?, + root_folder: row.get(5)?, + }) + })?; + out.extend(movie_rows.collect::>>()?); + + let mut pack_stmt = conn.prepare( + "SELECT r.id, r.media_item_id, r.torrent_hash, m.title, r.season_number, m.root_folder + FROM release r + JOIN media_item m ON m.id = r.media_item_id + WHERE r.status = 'grabbed' AND r.torrent_hash IS NOT NULL AND r.episode_id IS NULL AND r.season_number IS NOT NULL", + )?; + let pack_rows = pack_stmt.query_map([], |row| { + Ok(PendingGrab::SeasonPack { + release_id: row.get(0)?, + media_item_id: row.get(1)?, + torrent_hash: row.get(2)?, + series_title: row.get(3)?, + season_number: row.get(4)?, + root_folder: row.get(5)?, + }) + })?; + out.extend(pack_rows.collect::>>()?); + + Ok(out) +} + +fn locate_video_file(content_path: &Path) -> Result { + if content_path.is_file() { + return Ok(content_path.to_path_buf()); + } + largest_video_file(content_path) +} + +pub(crate) fn largest_video_file(dir: &Path) -> Result { + let mut best: Option<(PathBuf, u64)> = None; + for entry in walk_files(dir)? { + let ext = entry + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + if !VIDEO_EXTS.contains(&ext.as_str()) { + continue; + } + let size = std::fs::metadata(&entry)?.len(); + if best.as_ref().is_none_or(|(_, s)| size > *s) { + best = Some((entry, size)); + } + } + best.map(|(p, _)| p) + .with_context(|| format!("no video file found under {}", dir.display())) +} + +pub(crate) fn walk_files(dir: &Path) -> Result> { + let mut out = Vec::new(); + for entry in std::fs::read_dir(dir)? { + let path = entry?.path(); + if path.is_dir() { + out.extend(walk_files(&path)?); + } else { + out.push(path); + } + } + Ok(out) +} + +pub(crate) fn deterministic_filename( + series_title: &str, + season: u32, + episode: u32, + episode_title: Option<&str>, + ext: &str, +) -> String { + let series = sanitize(series_title); + match episode_title.filter(|t| !t.is_empty()) { + Some(t) => format!( + "{series} - S{season:02}E{episode:02} - {}.{ext}", + sanitize(t) + ), + None => format!("{series} - S{season:02}E{episode:02}.{ext}"), + } +} + +pub(crate) fn deterministic_movie_filename(title: &str, year: Option, ext: &str) -> String { + let title = sanitize(title); + match year { + Some(y) => format!("{title} ({y}).{ext}"), + None => format!("{title}.{ext}"), + } +} + +pub(crate) fn sanitize(s: &str) -> String { + s.chars() + .map(|c| if "/\\:*?\"<>|".contains(c) { '_' } else { c }) + .collect() +} + +/// True when there isn't enough free space at `dir` to hold `needed_bytes`. +/// Checked before every import — a library volume running out of space is a +/// real failure mode, and a plain `copy` with no such guard leaves an ENOSPC +/// partway through as a truncated file sitting at the final destination +/// path. `blocks_available` (rather than `blocks_free`) matches what `df` +/// reports, since it excludes space reserved for the superuser. +fn insufficient_space(dir: &Path, needed_bytes: u64) -> Result { + let stat = nix::sys::statvfs::statvfs(dir) + .with_context(|| format!("failed to stat filesystem for {}", dir.display()))?; + let available = stat.blocks_available() * stat.fragment_size(); + Ok(available < needed_bytes) +} + +/// Hardlinks into the destination (same filesystem, effectively free) and +/// falls back to a plain copy cross-filesystem — deliberately leaves `src` +/// untouched either way. The previous `rename`-then-delete behavior yanked +/// the file out of qBittorrent's payload directory on every import, leaving +/// qBittorrent holding a torrent whose data had vanished (an errored +/// "missing files" state, seeding stopped instantly). A hardlink costs +/// nothing extra on disk and lets qBittorrent keep seeding after import; +/// even the copy fallback preserves seeding, just at the cost of double +/// disk usage for that one file. +/// +/// The copy path writes to a `.part` sibling of `dest` and only renames it +/// into place once the copy is complete (a same-filesystem rename, so +/// atomic) — a crash mid-copy leaves an orphaned `.part` file rather than a +/// truncated file at `dest`, so a retried import can't ever double-count a +/// half-written file as already present. +fn link_or_copy_file(src: &Path, dest: &Path) -> Result<()> { + if std::fs::hard_link(src, dest).is_ok() { + return Ok(()); + } + copy_via_temp_file(src, dest) +} + +/// The copy fallback's actual mechanics, split out so it's directly +/// testable without needing a real cross-filesystem boundary to force +/// `hard_link` to fail. +fn copy_via_temp_file(src: &Path, dest: &Path) -> Result<()> { + let tmp = PathBuf::from(format!("{}.part", dest.display())); + std::fs::copy(src, &tmp) + .with_context(|| format!("failed to copy {} to {}", src.display(), tmp.display()))?; + std::fs::rename(&tmp, dest).with_context(|| { + format!( + "failed to move completed copy into place at {}", + dest.display() + ) + })?; + Ok(()) +} + +#[derive(Debug, Default, PartialEq)] +pub struct ImportStats { + pub imported: usize, + pub remuxed: usize, + pub skipped_incomplete: usize, + pub errors: usize, + pub failed: usize, + /// Freshly-imported files where the post-import ffprobe found a + /// ground-truth problem the title text didn't reveal (under-quality + /// despite a claimed-good resolution, or unreadable) — see + /// `probe_indicates_import_problem`. + pub quality_flagged: usize, +} + +/// How long a grab can sit with its progress unchanged before it's declared +/// stalled (dead seeders, a torrent that will never complete) and released +/// back into the search pool. +const STALL_THRESHOLD_HOURS: f64 = 72.0; + +/// How long a grab's torrent hash can be absent from qBittorrent's list +/// before it's treated as genuinely gone (manually removed, category +/// changed) rather than just not-yet-indexed since the grab. +const MISSING_GRACE_MINUTES: f64 = 15.0; + +/// Advances the release's progress watermark, but only when progress has +/// genuinely increased — `last_progress_at` staying still while progress +/// stays still is exactly the signal `grab_is_stalled` looks for. +fn update_grab_progress(conn: &Connection, release_id: i64, progress: f64) -> Result<()> { + let prev: Option = conn.query_row( + "SELECT last_seen_progress FROM release WHERE id = ?1", + params![release_id], + |row| row.get(0), + )?; + if prev.is_none_or(|p| progress > p) { + conn.execute( + "UPDATE release SET last_seen_progress = ?2, last_progress_at = datetime('now') WHERE id = ?1", + params![release_id, progress], + )?; + } + Ok(()) +} + +/// True once a grab has gone `STALL_THRESHOLD_HOURS` without its progress +/// advancing. Falls back to `grabbed_at` when progress has never been +/// observed advancing at all (stuck at the same percentage, including 0%, +/// since the moment it was grabbed). +fn grab_is_stalled(conn: &Connection, release_id: i64) -> Result { + conn.query_row( + "SELECT (julianday('now') - julianday(COALESCE(last_progress_at, grabbed_at))) * 24.0 > ?2 + FROM release WHERE id = ?1", + params![release_id, STALL_THRESHOLD_HOURS], + |row| row.get(0), + ) + .map_err(Into::into) +} + +/// True once a grabbed release's torrent hash has been missing from qBit's +/// torrent list for longer than a short grace period — long enough to rule +/// out qBit simply not having indexed a just-added torrent yet, short +/// enough that a torrent genuinely removed by hand doesn't block re-search +/// for days. +fn grab_missing_past_grace(conn: &Connection, release_id: i64) -> Result { + conn.query_row( + "SELECT (julianday('now') - julianday(grabbed_at)) * 24.0 * 60.0 > ?2 + FROM release WHERE id = ?1", + params![release_id, MISSING_GRACE_MINUTES], + |row| row.get(0), + ) + .map_err(Into::into) +} + +/// How many consecutive `import_one` failures a completed torrent gets +/// before it's given up on. A few retries absorb transient issues (a +/// filesystem hiccup, a momentarily-unavailable mount); beyond that, the +/// same error every cycle forever means it's not going to fix itself, and +/// leaving `status = 'grabbed'` blocks the episode/movie from ever being +/// re-searched via a different release. +const MAX_IMPORT_ERRORS: i64 = 5; + +/// Increments the release's import-error counter and returns the new +/// total, so the caller can decide whether it's crossed `MAX_IMPORT_ERRORS`. +fn record_import_error(conn: &Connection, release_id: i64) -> Result { + conn.execute( + "UPDATE release SET import_error_count = import_error_count + 1 WHERE id = ?1", + params![release_id], + )?; + conn.query_row( + "SELECT import_error_count FROM release WHERE id = ?1", + params![release_id], + |row| row.get(0), + ) + .map_err(Into::into) +} + +/// Marks a stalled or missing grab as failed. `'failed'` isn't in the +/// `('grabbed','downloading')` in-flight check used by +/// `enumerate_search_targets`/`movie_needs_grab`, so the episode or movie +/// becomes searchable again on the very next cycle — this is the fallback- +/// into-the-search-pool step, not just bookkeeping. +fn fail_grab(conn: &Connection, grab: &PendingGrab, reason: &str) -> Result<()> { + conn.execute( + "UPDATE release SET status = 'failed' WHERE id = ?1", + params![grab.release_id()], + )?; + crate::db::record_event( + conn, + grab.media_item_id(), + grab.episode_id(), + "failed", + reason, + )?; + tracing::warn!( + release_id = grab.release_id(), + media_item_id = grab.media_item_id(), + reason, + "grab failed, releasing back to the search pool" + ); + Ok(()) +} + +/// Result of a `reconcile_missing_files` pass. +#[derive(Debug, Default, PartialEq)] +pub struct ReconcileOutcome { + /// Rows whose file had simply moved (e.g. a show folder renamed since + /// the row was written) — found by basename under the owning + /// `root_folder` and repaired in place rather than cleared. + pub repaired: usize, + /// Rows genuinely gone: not at their stored path, and no file with the + /// same name found anywhere under `root_folder` either. + pub cleared: usize, + /// True when the pass found more "genuinely gone" candidates than + /// `RECONCILE_MAX_CLEAR_FRACTION` allows and refused to clear anything — + /// see its doc comment for why. + pub aborted: bool, +} + +/// Refuse to clear more than this fraction of all tracked files in a single +/// pass. A healthy library loses files one or two at a time; a mount going +/// offline, a bulk rename that outpaces the basename-repair fallback below, +/// or a bug can instead make *every* row look gone at once — exactly the +/// shape of failure that isn't self-evident from any single row, only from +/// the batch as a whole. +const RECONCILE_MAX_CLEAR_FRACTION: f64 = 0.10; + +/// ...but always allow at least this many, so a small library (where 10% is +/// less than one file) still self-heals from genuinely missing files. +const RECONCILE_MIN_CLEAR_FLOOR: usize = 10; + +/// Clears `episode_file`/`has_file` state for files that no longer exist on +/// disk — a file deleted or moved by hand outside breadarr (or Jellyfin's +/// own library tools) previously left stale state forever: the episode +/// looked permanently satisfied and never got re-searched, no matter how +/// long the file had actually been gone. Runs on a slow, separate ticker +/// (disk state doesn't change on its own) — see `main.rs`. +/// +/// Before treating a missing path as "gone," this looks for a file of the +/// same name (or, failing that, the same stem under a different video +/// extension — see `find_by_stem`) anywhere under the owning media item's +/// `root_folder` and repairs the stored path instead — the DB `path` column +/// is only ever written once, on import, so anything that changes a file's +/// location or name afterward (the library-normalization scan appending a +/// year to a show folder; Tdarr re-encoding a file to a different container +/// in place) silently strands every affected `episode_file.path` unless +/// something re-derives it. This is that something, and it also guards +/// against a transient mount outage (`root_folder` itself absent) being +/// misread as every file under it having vanished. +/// +/// `dry_run` computes and logs what would happen without writing anything — +/// used for a one-time report against a freshly-restored database before +/// trusting this to run unattended again. +pub fn reconcile_missing_files(conn: &Connection, dry_run: bool) -> Result { + // `episode_file.media_item_id` is NULL for TV rows written by the + // library scan (only `episode_id` is set there) and `episode_id` is + // NULL for movie rows — resolving the owning `root_folder` needs both + // joins, COALESCEd, or half the library silently skips repair. + let mut stmt = conn.prepare( + "SELECT ef.id, ef.episode_id, ef.path, + COALESCE(mi_direct.root_folder, mi_ep.root_folder) AS root_folder + FROM episode_file ef + LEFT JOIN media_item mi_direct ON mi_direct.id = ef.media_item_id + LEFT JOIN episode e ON e.id = ef.episode_id + LEFT JOIN media_item mi_ep ON mi_ep.id = e.media_item_id", + )?; + let rows: Vec<(i64, Option, String, Option)> = stmt + .query_map([], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + })? + .collect::>()?; + let total = rows.len(); + + let mut to_repair: Vec<(i64, String)> = Vec::new(); + let mut to_clear: Vec<(i64, Option, String)> = Vec::new(); + + for (file_id, episode_id, path, root_folder) in rows { + if Path::new(&path).exists() { + continue; + } + let Some(root_folder) = root_folder else { + tracing::warn!( + file_id, + path, + "missing file has no resolvable root_folder; skipping" + ); + continue; + }; + if !Path::new(&root_folder).is_dir() { + // The whole show/movie folder is absent — almost certainly a + // mount that isn't up yet, not hundreds of individually deleted + // files. Never clear on this basis. + tracing::warn!( + file_id, + root_folder, + "owning root_folder is absent (mount offline?); skipping" + ); + continue; + } + let stored_path = Path::new(&path); + let found = stored_path + .file_name() + .and_then(|name| find_by_basename(Path::new(&root_folder), name)) + .or_else(|| { + // Tdarr (this library's AV1-transcode pipeline) re-encodes a + // file in place and can land it under a different extension + // — same stem, e.g. `Upgrade (2018).mp4` becomes `Upgrade + // (2018).mkv`, with the original removed. An exact basename + // match won't see that; falling back to a stem match against + // known video extensions catches it exactly like a folder + // rename, instead of treating a freshly-transcoded file as a + // deletion. + let stem = stored_path.file_stem()?; + find_by_stem(Path::new(&root_folder), stem) + }); + match found { + Some(new_path) => to_repair.push((file_id, new_path.to_string_lossy().into_owned())), + None => to_clear.push((file_id, episode_id, path)), + } + } + + let clear_limit = (((total as f64) * RECONCILE_MAX_CLEAR_FRACTION).ceil() as usize) + .max(RECONCILE_MIN_CLEAR_FLOOR); + if to_clear.len() > clear_limit { + tracing::error!( + would_clear = to_clear.len(), + would_repair = to_repair.len(), + total, + clear_limit, + "reconcile wanted to clear an anomalous number of files in one pass — refusing \ + (a mount may be offline, or something changed how paths are laid out). \ + No rows were changed." + ); + return Ok(ReconcileOutcome { + aborted: true, + ..Default::default() + }); + } + + if dry_run { + for (file_id, new_path) in &to_repair { + tracing::info!(file_id, new_path, "[dry-run] would repair stale path"); + } + for (file_id, episode_id, path) in &to_clear { + tracing::info!(file_id, episode_id, path, "[dry-run] would clear"); + } + return Ok(ReconcileOutcome { + repaired: to_repair.len(), + cleared: to_clear.len(), + aborted: false, + }); + } + + // One transaction so a mid-run error can't leave the library half + // repaired/half cleared. + let tx = conn.unchecked_transaction()?; + for (file_id, new_path) in &to_repair { + tx.execute( + "UPDATE episode_file SET path = ?1 WHERE id = ?2", + params![new_path, file_id], + )?; + tracing::info!(file_id, new_path, "repaired stale episode_file path"); + } + for (file_id, episode_id, path) in &to_clear { + tx.execute("DELETE FROM episode_file WHERE id = ?1", params![file_id])?; + if let Some(episode_id) = episode_id { + tx.execute( + "UPDATE episode SET has_file = 0 WHERE id = ?1", + params![episode_id], + )?; + } + tracing::info!( + file_id, + episode_id, + path, + "cleared missing file from library state" + ); + } + tx.commit()?; + + Ok(ReconcileOutcome { + repaired: to_repair.len(), + cleared: to_clear.len(), + aborted: false, + }) +} + +/// First file found at any depth under `root` whose file name is exactly +/// `name` — used to relocate an `episode_file` row whose stored path no +/// longer exists but whose owning show/movie folder does, e.g. after the +/// folder itself was renamed. Bounded to one media item's own folder (a +/// handful of season directories at most), and safe from cross-show +/// collisions since deterministic filenames are unique within a show. +fn find_by_basename(root: &Path, name: &std::ffi::OsStr) -> Option { + let entries = std::fs::read_dir(root).ok()?; + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if path.is_dir() { + if let Some(found) = find_by_basename(&path, name) { + return Some(found); + } + } else if path.file_name() == Some(name) { + return Some(path); + } + } + None +} + +/// First file found at any depth under `root` with the given file stem +/// (name minus extension) and a recognized video extension — the +/// transcode-in-place counterpart to `find_by_basename`: a re-encode can +/// change the container/extension (e.g. Tdarr converting `.mp4` to `.mkv`) +/// while keeping the stem, which an exact-name match won't see. +fn find_by_stem(root: &Path, stem: &std::ffi::OsStr) -> Option { + let entries = std::fs::read_dir(root).ok()?; + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if path.is_dir() { + if let Some(found) = find_by_stem(&path, stem) { + return Some(found); + } + } else { + let ext_is_video = path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| VIDEO_EXTS.contains(&e.to_lowercase().as_str())); + if ext_is_video && path.file_stem() == Some(stem) { + return Some(path); + } + } + } + None +} + +/// Runs ffprobe on `path` and upserts the result into `media_file_probe` +/// against `episode_file_id` — but only if the file's size or mtime has +/// actually changed since the last probe, so a routine sweep over a large, +/// mostly-unchanged library is cheap after the first pass. This is also +/// what keeps a probe from silently describing stale content after +/// `reconcile_missing_files` repairs a renamed/transcoded file onto the +/// same `episode_file.id` — the freshness check catches the change and +/// forces a re-probe. Returns `true` if a probe actually ran, `false` if +/// skipped as already up to date. ffprobe itself failing (missing binary, +/// unreadable/corrupt file) is recorded as `corruption_status = +/// 'probe_failed'` rather than propagated — probing must never abort a +/// scan or import. +pub fn ensure_probed(conn: &Connection, episode_file_id: i64, path: &Path) -> Result { + let metadata = std::fs::metadata(path) + .with_context(|| format!("failed to stat {} for probing", path.display()))?; + let size = metadata.len() as i64; + let mtime = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + let existing: Option<(i64, i64)> = conn + .query_row( + "SELECT probe_size_bytes, probe_mtime FROM media_file_probe WHERE episode_file_id = ?1", + params![episode_file_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + if existing == Some((size, mtime)) { + return Ok(false); + } + + match ffprobe::probe(path) { + Ok(probe) => upsert_probe(conn, episode_file_id, size, mtime, Some(&probe), "probe_ok")?, + Err(e) => { + tracing::warn!( + episode_file_id, + path = %path.display(), + error = %e, + "ffprobe failed; recording as probe_failed and continuing" + ); + upsert_probe(conn, episode_file_id, size, mtime, None, "probe_failed")?; + } + } + Ok(true) +} + +#[allow(clippy::too_many_arguments)] +/// Everything `upsert_probe` needs derived from a `MediaProbe` (or blanked +/// out entirely when probing itself failed) — a struct rather than the +/// wall-of-positional-`Option`s this used to be, since that stopped being +/// readable once the extra-metadata fields were added. +#[derive(Default)] +struct ProbeFields { + duration: Option, + container_bitrate: Option, + container_format: Option, + video_codec: Option, + width: Option, + height: Option, + video_bitrate: Option, + frame_rate: Option, + hdr: bool, + color_transfer: Option, + audio_codecs: Option, + audio_langs: Option, + default_audio_lang: Option, + default_audio_channels: Option, + subtitle_langs: Option, + raw_json: Option, + under_quality: bool, + no_subs: bool, + no_eng_audio: bool, + non_eng_default: bool, +} + +impl ProbeFields { + fn from_probe(p: &ffprobe::MediaProbe) -> Self { + let audio_codecs = p + .audio + .iter() + .filter_map(|a| a.codec.clone()) + .collect::>() + .join(","); + let audio_langs = p + .audio + .iter() + .filter_map(|a| a.language.clone()) + .collect::>() + .join(","); + let default_audio = p.audio.iter().find(|a| a.is_default); + let subtitle_langs = p + .subtitles + .iter() + .filter_map(|s| s.language.clone()) + .collect::>() + .join(","); + Self { + duration: p.duration_secs, + container_bitrate: p.container_bitrate, + container_format: p.container_format.clone(), + video_codec: p.video_codec.clone(), + width: p.width, + height: p.height, + video_bitrate: p.video_bitrate, + frame_rate: p.frame_rate, + hdr: p.is_hdr(), + color_transfer: p.color_transfer.clone(), + audio_codecs: Some(audio_codecs), + audio_langs: Some(audio_langs), + default_audio_lang: default_audio.and_then(|a| a.language.clone()), + default_audio_channels: default_audio.and_then(|a| a.channels), + subtitle_langs: Some(subtitle_langs), + raw_json: Some(p.raw_json.clone()), + under_quality: p.height.is_some_and(|h| h < UNDER_QUALITY_HEIGHT) + && p.width.is_some_and(|w| w < UNDER_QUALITY_WIDTH), + no_subs: p.subtitles.is_empty(), + no_eng_audio: !p.has_english_audio(), + non_eng_default: p.default_audio_is_non_english(), + } + } +} + +fn upsert_probe( + conn: &Connection, + episode_file_id: i64, + size: i64, + mtime: i64, + probe: Option<&ffprobe::MediaProbe>, + corruption_status: &str, +) -> Result<()> { + let f = probe.map(ProbeFields::from_probe).unwrap_or_default(); + + conn.execute( + "INSERT INTO media_file_probe ( + episode_file_id, probed_at, probe_size_bytes, probe_mtime, + duration_secs, container_bitrate, container_format, + video_codec, width, height, video_bitrate, frame_rate, hdr, color_transfer, + audio_codecs, audio_langs, default_audio_lang, default_audio_channels, subtitle_langs, + raw_ffprobe_json, + corruption_status, corruption_checked_at, + flag_under_quality, flag_no_subtitles, flag_no_english_audio, flag_non_english_default_audio + ) VALUES (?1, datetime('now'), ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, NULL, ?21, ?22, ?23, ?24) + ON CONFLICT(episode_file_id) DO UPDATE SET + probed_at = excluded.probed_at, + probe_size_bytes = excluded.probe_size_bytes, + probe_mtime = excluded.probe_mtime, + duration_secs = excluded.duration_secs, + container_bitrate = excluded.container_bitrate, + container_format = excluded.container_format, + video_codec = excluded.video_codec, + width = excluded.width, + height = excluded.height, + video_bitrate = excluded.video_bitrate, + frame_rate = excluded.frame_rate, + hdr = excluded.hdr, + color_transfer = excluded.color_transfer, + audio_codecs = excluded.audio_codecs, + audio_langs = excluded.audio_langs, + default_audio_lang = excluded.default_audio_lang, + default_audio_channels = excluded.default_audio_channels, + subtitle_langs = excluded.subtitle_langs, + raw_ffprobe_json = excluded.raw_ffprobe_json, + corruption_status = excluded.corruption_status, + corruption_checked_at = NULL, + flag_under_quality = excluded.flag_under_quality, + flag_no_subtitles = excluded.flag_no_subtitles, + flag_no_english_audio = excluded.flag_no_english_audio, + flag_non_english_default_audio = excluded.flag_non_english_default_audio", + params![ + episode_file_id, + size, + mtime, + f.duration, + f.container_bitrate, + f.container_format, + f.video_codec, + f.width, + f.height, + f.video_bitrate, + f.frame_rate, + f.hdr, + f.color_transfer, + f.audio_codecs, + f.audio_langs, + f.default_audio_lang, + f.default_audio_channels, + f.subtitle_langs, + f.raw_json, + corruption_status, + f.under_quality, + f.no_subs, + f.no_eng_audio, + f.non_eng_default, + ], + )?; + Ok(()) +} + +/// Records the outcome of the expensive full-decode corruption check (see +/// `ffprobe::verify_decodable`) against an already-probed row. Separate +/// from `upsert_probe` since this is only ever called by the opt-in +/// `verify-library` pass, never by a routine probe. +pub fn record_decode_check( + conn: &Connection, + episode_file_id: i64, + result: &ffprobe::DecodeCheck, +) -> Result<()> { + let status = match result { + ffprobe::DecodeCheck::Ok => "decode_ok", + ffprobe::DecodeCheck::Corrupt(_) => "decode_failed", + }; + conn.execute( + "UPDATE media_file_probe SET corruption_status = ?1, corruption_checked_at = datetime('now') + WHERE episode_file_id = ?2", + params![status, episode_file_id], + )?; + Ok(()) +} + +/// True when a just-probed file has a problem worth alerting on +/// immediately (used for the post-import ground-truth quality check) — +/// deliberately narrower than "any flag is set": `flag_no_subtitles` and +/// `flag_non_english_default_audio` are common and already handled +/// elsewhere (the latter is fixed automatically by the remux-at-import step +/// right before this ever runs), so surfacing them here on every single +/// import would just be noise. Under-quality and probe failure are the two +/// signals that mean "this import silently isn't what it claimed to be." +fn probe_indicates_import_problem(conn: &Connection, episode_file_id: i64) -> Result { + conn.query_row( + "SELECT flag_under_quality OR corruption_status = 'probe_failed' + FROM media_file_probe WHERE episode_file_id = ?1", + params![episode_file_id], + |row| row.get(0), + ) + .optional() + .map(|r| r.unwrap_or(false)) + .map_err(Into::into) +} + +#[derive(Debug, Default)] +pub struct ProbeSweepReport { + pub probed: usize, + pub skipped_up_to_date: usize, + pub failed: usize, +} + +/// Incremental sweep over every tracked file: probes anything not probed +/// yet, or whose content has drifted (size/mtime) since it last was — the +/// backlog-and-drift half of keeping `media_file_probe` current, alongside +/// the inline probing done right after a file is linked or imported. Files +/// missing from disk are left alone (that's `reconcile_missing_files`'s +/// job, not this one's) rather than double-handled here. +/// Caps how many files actually get *probed* (not just checked-and-skipped) +/// in one `probe_library` call. Skipping this check runs an unbounded first +/// sweep against a large existing library in a single pass — each probe +/// spawns a real `ffprobe` subprocess, and the whole call holds the shared +/// `Connection` mutex the entire time (the same mutex `main.rs`'s grab/ +/// import/search tickers need), so an unbounded sweep would stall those +/// cycles for however long a full library backlog takes. Capping it instead +/// spreads a large backlog across successive hourly ticks — see +/// `main.rs`'s reconcile ticker, which this shares a tick with. +const PROBE_SWEEP_BATCH_LIMIT: usize = 200; + +pub fn probe_library(conn: &Connection) -> Result { + let mut stmt = conn.prepare("SELECT id, path FROM episode_file")?; + let rows: Vec<(i64, String)> = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::>()?; + + let mut report = ProbeSweepReport::default(); + for (episode_file_id, path) in rows { + if report.probed >= PROBE_SWEEP_BATCH_LIMIT { + break; + } + let p = Path::new(&path); + if !p.exists() { + continue; + } + match ensure_probed(conn, episode_file_id, p) { + Ok(true) => report.probed += 1, + Ok(false) => report.skipped_up_to_date += 1, + Err(e) => { + tracing::warn!(episode_file_id, path, error = %e, "probe sweep: failed to probe file"); + report.failed += 1; + } + } + } + Ok(report) +} + +#[derive(Debug, Default)] +pub struct VerifyLibraryReport { + pub verified_ok: usize, + pub corrupt: usize, + pub errors: usize, +} + +/// Runs the expensive full-decode corruption check +/// (`ffprobe::verify_decodable`) against every file whose cheap header +/// probe succeeded but hasn't yet been decode-verified +/// (`corruption_status = 'probe_ok'`) — confirming a file actually decodes +/// end-to-end, not just that its container header parses. `probe_failed` +/// files are skipped: ffprobe already couldn't parse them, so a decode +/// attempt would only confirm the same failure at much higher cost. +/// Deliberately never run by any ticker (this can take minutes per file — +/// see `ffprobe::verify_decodable`'s doc comment) — only ever runs when +/// explicitly invoked via `breadarrd verify-library`. +pub fn verify_library(conn: &Connection) -> Result { + let mut stmt = conn.prepare( + "SELECT ef.id, ef.path FROM episode_file ef + JOIN media_file_probe p ON p.episode_file_id = ef.id + WHERE p.corruption_status = 'probe_ok'", + )?; + let rows: Vec<(i64, String)> = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::>()?; + + let mut report = VerifyLibraryReport::default(); + for (episode_file_id, path) in rows { + let source_path = Path::new(&path); + if !source_path.exists() { + continue; // reconcile's job, not ours + } + match ffprobe::verify_decodable(source_path) { + Ok(result) => { + record_decode_check(conn, episode_file_id, &result)?; + match result { + ffprobe::DecodeCheck::Ok => { + report.verified_ok += 1; + tracing::info!(episode_file_id, path, "verify library: decodes cleanly"); + } + ffprobe::DecodeCheck::Corrupt(detail) => { + report.corrupt += 1; + tracing::warn!( + episode_file_id, + path, + detail, + "verify library: decode failed, file looks corrupt" + ); + } + } + } + Err(e) => { + tracing::warn!(episode_file_id, path, error = %e, "verify library: failed to run decode check"); + report.errors += 1; + } + } + } + Ok(report) +} + +#[derive(Debug, Default)] +pub struct RemuxBacklogReport { + pub remuxed: usize, + pub skipped_not_mkv: usize, + pub skipped_no_english_track: usize, + pub errors: usize, +} + +/// Applies the same "promote the English audio track to default" fix +/// (`mkv::remux_english_default`) that already runs automatically right +/// after a fresh download, but against files *already* sitting in the +/// library — the backlog that existed before this feature shipped, or any +/// file `media_file_probe` has flagged since. Deliberately not wired to any +/// automatic ticker (unlike `probe_library`, which is read-mostly): this +/// rewrites real files in the library, so it only ever runs when explicitly +/// invoked (see `breadarrd remux-backlog`). +/// +/// Only `.mkv` files are eligible — `mkv::remux_english_default` retags +/// tracks via `mkvmerge`, the same constraint the at-import-time fix +/// already has. +pub fn remux_backlog(conn: &Connection) -> Result { + let mut stmt = conn.prepare( + "SELECT ef.id, ef.path FROM episode_file ef + JOIN media_file_probe p ON p.episode_file_id = ef.id + WHERE p.flag_non_english_default_audio = 1", + )?; + let rows: Vec<(i64, String)> = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::>()?; + + let mut report = RemuxBacklogReport::default(); + for (episode_file_id, path) in rows { + let source_path = Path::new(&path); + if !source_path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("mkv")) + { + report.skipped_not_mkv += 1; + continue; + } + if !source_path.exists() { + continue; // reconcile's job, not ours + } + + match remux_one_backlog_file(conn, episode_file_id, source_path) { + Ok(true) => report.remuxed += 1, + Ok(false) => report.skipped_no_english_track += 1, + Err(e) => { + tracing::warn!(episode_file_id, path, error = %e, "remux backlog: failed to remux file"); + report.errors += 1; + } + } + } + Ok(report) +} + +/// Returns `true` if a remux actually happened, `false` if there was no +/// English track to promote (nothing to do — not an error). +fn remux_one_backlog_file(conn: &Connection, episode_file_id: i64, path: &Path) -> Result { + let tracks = mkv::inspect_audio_tracks(path)?; + if !mkv::has_english_track(&tracks) { + return Ok(false); + } + + let tmp = path.with_extension("fixed.mkv"); + mkv::remux_english_default(path, &tmp, &tracks)?; + + // Same atomic-swap shape as `copy_via_temp_file`: rename the freshly + // remuxed output over the original on the same filesystem, so a crash + // mid-swap can never leave a half-written file at the real path. + std::fs::rename(&tmp, path)?; + + let size_bytes = std::fs::metadata(path)?.len(); + conn.execute( + "UPDATE episode_file SET size_bytes = ?1 WHERE id = ?2", + params![size_bytes, episode_file_id], + )?; + // Force a re-probe even though size may coincidentally match — a + // flag-only remux barely changes file size, so the freshness check + // could otherwise skip it. Clearing the stored probe row is simpler + // than adding a "force" parameter to `ensure_probed` for this one caller. + conn.execute( + "DELETE FROM media_file_probe WHERE episode_file_id = ?1", + params![episode_file_id], + )?; + ensure_probed(conn, episode_file_id, path)?; + + Ok(true) +} + +/// Translates a path qBittorrent reports via its API into one breadarr can +/// actually open — needed when qBittorrent runs in a container (its own +/// downloads mounted at some internal prefix like "/downloads") while +/// breadarr runs natively on the same host. A no-op when either prefix is +/// empty (qBittorrent's reported path is used as-is). +fn remap_path(reported: &str, container_prefix: &str, host_prefix: &str) -> PathBuf { + if container_prefix.is_empty() || host_prefix.is_empty() { + return PathBuf::from(reported); + } + match reported.strip_prefix(container_prefix) { + Some(rest) => PathBuf::from(format!("{host_prefix}{rest}")), + None => PathBuf::from(reported), + } +} + +/// Marker directory name for the seeding-preserving staging area — checked +/// as a plain substring of a torrent's reported `content_path` to tell +/// whether it's already been relocated there in a previous cycle. +const STAGING_DIR_NAME: &str = ".breadarr-staging"; + +/// How many times to poll qBittorrent for a `setLocation` move to finish +/// before giving up for this cycle (it'll simply be retried next cycle — +/// see `relocate_completed_to_staging`). +const RELOCATE_POLL_ATTEMPTS: u32 = 5; +const RELOCATE_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2); + +/// Finds the nearest ancestor of `path` that actually exists — `path` +/// itself (e.g. a show's season folder) may not have been created yet. +fn nearest_existing_ancestor(path: &Path) -> Result { + let mut current = path; + loop { + if current.exists() { + return Ok(current.to_path_buf()); + } + current = current + .parent() + .with_context(|| format!("no existing ancestor found for {}", path.display()))?; + } +} + +/// Finds the filesystem mount-point directory containing `path`, by +/// walking up parents until the device id changes. Used to place the +/// seeding-staging directory on the same physical filesystem as the final +/// destination — the whole point of staging is that the later hardlink-out +/// in `import_one` is guaranteed to succeed as a true hardlink rather than +/// silently falling back to a full copy, and a hardlink can never cross a +/// filesystem boundary. +fn find_mount_root(path: &Path) -> Result { + use std::os::unix::fs::MetadataExt; + let start = nearest_existing_ancestor(path)?; + let dev = std::fs::metadata(&start)?.dev(); + let mut current = start; + loop { + let Some(parent) = current.parent() else { + return Ok(current); + }; + let Ok(parent_meta) = std::fs::metadata(parent) else { + return Ok(current); + }; + if parent_meta.dev() != dev { + return Ok(current); + } + current = parent.to_path_buf(); + } +} + +/// The staging directory a given torrent's data should be relocated to — +/// on the same filesystem as `dest_dir`, named by torrent hash so multiple +/// torrents' leftover extras (samples/nfo/screenshots) never collide. +fn staging_dir_for(dest_dir: &Path, torrent_hash: &str) -> Result { + let mount_root = find_mount_root(dest_dir)?; + Ok(mount_root.join(STAGING_DIR_NAME).join(torrent_hash)) +} + +/// For every pending grab whose torrent has finished downloading but whose +/// data isn't already staged, relocates it via qBittorrent's own +/// `setLocation` to a directory on the same filesystem as its eventual +/// destination, then waits (briefly, bounded) for the move to actually +/// finish — `setLocation` returns before the physical move completes. +/// +/// Best-effort and self-healing by design: a grab that isn't staged yet +/// this cycle is simply retried on the next one (nothing here ever fails +/// the whole import cycle), so a slow move or a transient qBit API error +/// never blocks or loses anything — it just costs one extra cycle. +/// +/// Returns whether anything was actually relocated, so the caller knows +/// whether it's worth re-fetching the torrent list before importing (a +/// relocated torrent's `content_path` only reflects its new home after a +/// fresh `list_torrents` call). +async fn relocate_completed_to_staging( + qbit: &QbitClient, + pending: &[PendingGrab], + torrents: &[crate::qbit::TorrentInfo], + category: &str, +) -> bool { + let mut relocated_any = false; + for grab in pending { + let Some(torrent) = torrents.iter().find(|t| t.hash == grab.torrent_hash()) else { + continue; + }; + if torrent.progress < 1.0 { + continue; + } + if torrent.content_path.contains(STAGING_DIR_NAME) { + continue; // already staged in a previous cycle + } + + let staging = match staging_dir_for(Path::new(grab.root_folder()), grab.torrent_hash()) { + Ok(s) => s, + Err(e) => { + tracing::warn!( + error = %e, + hash = grab.torrent_hash(), + "could not determine a staging directory this cycle" + ); + continue; + } + }; + + if let Err(e) = qbit + .set_location(grab.torrent_hash(), &staging.to_string_lossy()) + .await + { + tracing::warn!( + error = %e, + hash = grab.torrent_hash(), + "qbit relocate-to-staging failed, will retry next cycle" + ); + continue; + } + + if wait_for_relocation(qbit, grab.torrent_hash(), category, &staging).await { + relocated_any = true; + } else { + tracing::warn!( + hash = grab.torrent_hash(), + "qbit relocate-to-staging didn't finish in time, will retry next cycle" + ); + } + } + relocated_any +} + +/// Waits for qBittorrent to report the torrent's `content_path` as staged, +/// then verifies the reported path actually landed where expected — +/// qBittorrent (when it's running in its own Docker container) reports *its +/// own* filesystem view, and `staging_dir_for`/`set_location` currently +/// rely on every library mount being set up as an identity mount (container +/// path == host path) for that reported path to be directly meaningful +/// from the host's side. That's a deployment convention, not something the +/// code enforces, so it can be wrong for a given install's mount layout. If +/// it is, treating the reported path as trustworthy without checking could +/// let `import_one`'s later hardlink-out silently fall back to a full +/// cross-device copy (or fail outright) instead of the guaranteed-cheap +/// hardlink staging exists to provide — so this confirms the reported path +/// resolves, from the host, to the *same physical filesystem* as +/// `expected_staging` before trusting it. +async fn wait_for_relocation( + qbit: &QbitClient, + hash: &str, + category: &str, + expected_staging: &Path, +) -> bool { + use std::os::unix::fs::MetadataExt; + + for _ in 0..RELOCATE_POLL_ATTEMPTS { + tokio::time::sleep(RELOCATE_POLL_INTERVAL).await; + let Ok(torrents) = qbit.list_torrents(Some(category)).await else { + continue; + }; + let Some(t) = torrents.iter().find(|t| t.hash == hash) else { + continue; + }; + if !t.content_path.contains(STAGING_DIR_NAME) { + continue; + } + let reported = Path::new(&t.content_path); + match ( + std::fs::metadata(reported), + std::fs::metadata(expected_staging), + ) { + (Ok(reported_meta), Ok(expected_meta)) + if reported_meta.dev() == expected_meta.dev() => + { + return true; + } + _ => { + tracing::error!( + hash, + reported = %t.content_path, + expected = %expected_staging.display(), + "qbit reports the torrent as staged, but its content_path isn't visible \ + on the same host filesystem as expected — the container's mount for this \ + library path may not be an identity mount; refusing to trust this relocation" + ); + return false; + } + } + } + false +} + +pub async fn run_import_cycle( + conn: &Connection, + qbit: &QbitClient, + jellyfin: Option<&JellyfinClient>, + category: &str, + container_downloads_path: &str, + host_downloads_path: &str, +) -> Result { + let pending = fetch_pending_grabs(conn)?; + if pending.is_empty() { + return Ok(ImportStats::default()); + } + + let torrents = qbit.list_torrents(Some(category)).await?; + + // Relocate completed-but-not-yet-staged torrents onto the same + // filesystem as their destination *before* importing, so the + // hardlink-out below (`link_or_copy_file`, unchanged) is a true + // hardlink instead of a full cross-drive copy — qBittorrent keeps + // seeding indefinitely from the staged location afterward, with no + // permanent second copy of the data anywhere. + let relocated_any = relocate_completed_to_staging(qbit, &pending, &torrents, category).await; + let torrents = if relocated_any { + qbit.list_torrents(Some(category)).await? + } else { + torrents + }; + + let stats = process_pending_grabs( + conn, + &pending, + &torrents, + container_downloads_path, + host_downloads_path, + )?; + + if stats.imported > 0 { + if let Some(jellyfin) = jellyfin { + jellyfin.refresh_library().await?; + } + } + + Ok(stats) +} + +/// The actual hash-matching, progress-gating, stall/missing-detection, and +/// import decision logic — split out from `run_import_cycle` so it's +/// testable against a synthetic `&[TorrentInfo]` instead of requiring a +/// real qBittorrent server. `run_import_cycle`'s only other job is the one +/// network call (`list_torrents`) and the post-import Jellyfin refresh, +/// neither of which this function touches. This is exactly the kind of +/// unmockable I/O seam that let the `MagnetRejected` silent-failure bug +/// hide for as long as it did. +fn process_pending_grabs( + conn: &Connection, + pending: &[PendingGrab], + torrents: &[crate::qbit::TorrentInfo], + container_downloads_path: &str, + host_downloads_path: &str, +) -> Result { + let mut stats = ImportStats::default(); + + for grab in pending { + let Some(torrent) = torrents.iter().find(|t| t.hash == grab.torrent_hash()) else { + if grab_missing_past_grace(conn, grab.release_id())? { + fail_grab(conn, grab, "torrent hash absent from qBittorrent")?; + stats.failed += 1; + } + continue; + }; + if torrent.progress < 1.0 { + stats.skipped_incomplete += 1; + update_grab_progress(conn, grab.release_id(), torrent.progress)?; + if grab_is_stalled(conn, grab.release_id())? { + fail_grab( + conn, + grab, + "no progress for longer than the stall threshold", + )?; + stats.failed += 1; + } + continue; + } + if torrent.state == "moving" { + // qBittorrent's own `setLocation` relocation (or a manual move) + // is still physically in flight — `content_path` may already + // point at the new location while the actual bytes are still + // being copied there. Importing now risks hardlinking/copying + // a partially-moved (truncated) file into the library and + // marking it complete. Simply wait; this is checked every + // cycle, so it proceeds as soon as the move finishes. + stats.skipped_incomplete += 1; + continue; + } + + let content_path = remap_path( + &torrent.content_path, + container_downloads_path, + host_downloads_path, + ); + + if let PendingGrab::SeasonPack { + release_id, + media_item_id, + series_title, + season_number, + root_folder, + .. + } = grab + { + match import_season_pack( + conn, + *release_id, + *media_item_id, + series_title, + *season_number, + root_folder, + &content_path, + ) { + Ok(outcome) => { + stats.imported += outcome.episodes_imported; + stats.quality_flagged += outcome.quality_flagged; + } + Err(e) => { + let error_count = record_import_error(conn, *release_id)?; + if error_count >= MAX_IMPORT_ERRORS { + fail_grab( + conn, + grab, + &format!("season pack import failed {error_count} times in a row: {e}"), + )?; + stats.failed += 1; + } else { + tracing::warn!( + error = %e, + release_id = *release_id, + error_count, + "season pack import failed" + ); + stats.errors += 1; + } + } + } + continue; + } + + match import_one(conn, grab, &content_path) { + Ok(ImportOutcome::Imported { + remuxed, + quality_flagged, + }) => { + stats.imported += 1; + if remuxed { + stats.remuxed += 1; + } + if quality_flagged { + stats.quality_flagged += 1; + } + } + Ok(ImportOutcome::SkippedAlreadyHaveBetter) => {} + Err(e) => { + let error_count = record_import_error(conn, grab.release_id())?; + if error_count >= MAX_IMPORT_ERRORS { + fail_grab( + conn, + grab, + &format!("import failed {error_count} times in a row: {e}"), + )?; + stats.failed += 1; + } else { + tracing::warn!( + error = %e, + release_id = grab.release_id(), + error_count, + "import failed" + ); + stats.errors += 1; + } + } + } + } + + Ok(stats) +} + +/// What actually happened when `import_one` was asked to import a +/// completed torrent — distinct from an error, `SkippedAlreadyHaveBetter` +/// is a deliberate no-op (see `import_one`'s dest-collision check), not a +/// failure, so callers shouldn't count it toward `ImportStats::imported`. +enum ImportOutcome { + Imported { + remuxed: bool, + quality_flagged: bool, + }, + SkippedAlreadyHaveBetter, +} + +fn import_one(conn: &Connection, grab: &PendingGrab, content_path: &Path) -> Result { + let source_path = locate_video_file(content_path)?; + let ext = source_path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("mkv") + .to_string(); + + let mut remuxed = false; + let mut working_path = source_path.clone(); + + if ext.eq_ignore_ascii_case("mkv") { + let tracks = mkv::inspect_audio_tracks(&source_path)?; + let needs_fix = + !mkv::default_track_is_english_or_unset(&tracks) && mkv::has_english_track(&tracks); + if needs_fix { + let tmp = source_path.with_extension("fixed.mkv"); + mkv::remux_english_default(&source_path, &tmp, &tracks)?; + // `source_path` is qBittorrent's actual seeding payload for + // this torrent — deleting it here, before the free-space check + // and the import below have even run, defeats the whole + // staging design (whose point is to keep seeding intact) and + // risks real data loss if either subsequent step fails: the + // original would already be gone, `working_path` might not + // have made it into the library either, and qBittorrent can't + // necessarily re-fetch a dead swarm. `link_or_copy_file` + // already leaves its source untouched for exactly this reason + // in the non-remux path (see its own doc comment) — the remux + // scratch output below gets the same treatment: only removed + // once it's been successfully imported. + working_path = tmp; + remuxed = true; + } + // Anime-JP-fallback case (no English track at all, allowed through + // by the gate for anime) has nothing to fix — falls through to a + // clean import below, same as an already-English-default file. + } + + let (root_folder, filename, episode_id) = match grab { + PendingGrab::Episode { + series_title, + season_number, + episode_number, + episode_title, + root_folder, + episode_id, + .. + } => { + let filename = deterministic_filename( + series_title, + *season_number, + *episode_number, + episode_title.as_deref(), + &ext, + ); + (root_folder.as_str(), filename, Some(*episode_id)) + } + PendingGrab::Movie { + title, + year, + root_folder, + .. + } => { + let filename = deterministic_movie_filename(title, *year, &ext); + (root_folder.as_str(), filename, None) + } + // `process_pending_grabs` dispatches `SeasonPack` to + // `import_season_pack` and never reaches this function with one. + PendingGrab::SeasonPack { .. } => unreachable!( + "SeasonPack grabs are handled by import_season_pack before import_one is ever called" + ), + }; + + std::fs::create_dir_all(root_folder)?; + let dest = Path::new(root_folder).join(&filename); + + // The deterministic filename means a second release for the same + // episode/movie collides on this exact path. `link_or_copy_file`'s copy + // fallback renames into place, which *silently overwrites* an existing + // file with no comparison at all — a lower-scored duplicate arriving + // second (e.g. a 480p release importing after an already-imported + // 1080p one, entirely possible before resolution was scored, and still + // possible from a race between two grabs of the same episode) would + // quietly replace the better file already in the library. Checked here + // rather than left to the filesystem to decide by import order. + if dest.exists() { + let current_score: f32 = conn + .query_row( + "SELECT score FROM release WHERE id = ?1", + params![grab.release_id()], + |row| row.get::<_, Option>(0), + )? + .unwrap_or(0.0); + let existing_best: Option = conn.query_row( + "SELECT MAX(score) FROM release WHERE status = 'imported' AND id != ?1 + AND ((episode_id = ?2 AND ?2 IS NOT NULL) OR (media_item_id = ?3 AND ?2 IS NULL))", + params![grab.release_id(), episode_id, grab.media_item_id()], + |row| row.get(0), + )?; + if existing_best.is_some_and(|best| best >= current_score) { + conn.execute( + "UPDATE release SET status = 'upgraded' WHERE id = ?1", + params![grab.release_id()], + )?; + crate::db::record_event( + conn, + grab.media_item_id(), + episode_id, + // event_history.event_type's CHECK constraint doesn't have + // an "upgraded" value (unlike release.status, which does) — + // reusing "failed" here rather than a schema migration for + // one more enum value; the detail text carries the real + // reason. + "failed", + &format!( + "skipped import: already have a file at {} scoring {:.1} or better (this release scored {current_score:.1})", + dest.display(), + existing_best.unwrap_or(0.0) + ), + )?; + if remuxed { + std::fs::remove_file(&working_path).ok(); + } + return Ok(ImportOutcome::SkippedAlreadyHaveBetter); + } + // The new file scores strictly better than what's currently there — + // remove the stale file and its tracking row before placing the + // replacement, rather than letting `link_or_copy_file`'s copy + // fallback silently overwrite it in place. This matters for two + // reasons: it keeps exactly one `episode_file` row per episode (an + // old row left behind plus the fresh `INSERT` below would otherwise + // leave a duplicate — the same bug class found live in the + // Dr.STONE/Battlestar Galactica rows), and it lets the primary + // hardlink path actually succeed (`std::fs::hard_link` fails + // outright if the destination already exists), so an upgrade is a + // cheap hardlink swap instead of an unnecessary full copy. + conn.execute( + "DELETE FROM episode_file WHERE path = ?1", + params![dest.to_string_lossy()], + )?; + std::fs::remove_file(&dest).ok(); + } + + let needed_bytes = std::fs::metadata(&working_path)?.len(); + if insufficient_space(Path::new(root_folder), needed_bytes)? { + anyhow::bail!( + "not enough free space at {root_folder} for {needed_bytes} bytes (source: {})", + working_path.display() + ); + } + + link_or_copy_file(&working_path, &dest)?; + if remuxed { + // `working_path` here is the scratch remux output, not qBittorrent's + // original content file (which was already removed above, in the + // remux branch, to make way for it) — nothing else reads it, so + // unlike the plain-import case there's no seeding reason to keep it. + std::fs::remove_file(&working_path).ok(); + } + + let size_bytes = std::fs::metadata(&dest)?.len(); + conn.execute( + "INSERT INTO episode_file (episode_id, media_item_id, path, size_bytes, subtitle_status) VALUES (?1, ?2, ?3, ?4, 'none')", + params![episode_id, grab.media_item_id(), dest.to_string_lossy(), size_bytes], + )?; + let episode_file_id = conn.last_insert_rowid(); + if let Some(episode_id) = episode_id { + conn.execute( + "UPDATE episode SET has_file = 1 WHERE id = ?1", + params![episode_id], + )?; + } + conn.execute( + "UPDATE release SET status = 'imported' WHERE id = ?1", + params![grab.release_id()], + )?; + crate::db::record_event( + conn, + grab.media_item_id(), + episode_id, + "imported", + &format!("path={} remuxed={remuxed}", dest.display()), + )?; + + // Ground-truth check on the file that actually landed, not the + // release's claimed title text — this is what catches e.g. an + // untagged-resolution release that turns out to really be SD. Probing + // failure itself must never fail an otherwise-successful import. + let quality_flagged = match ensure_probed(conn, episode_file_id, &dest) { + Ok(_) => probe_indicates_import_problem(conn, episode_file_id)?, + Err(e) => { + tracing::warn!(episode_file_id, error = %e, "post-import probing failed"); + false + } + }; + if quality_flagged { + crate::db::record_event( + conn, + grab.media_item_id(), + episode_id, + // event_history's event_type CHECK has no dedicated value for + // this — reusing "failed" per the same established workaround + // used elsewhere in this file (see the dest-collision skip case + // above); the detail text carries the real reason. + "failed", + &format!( + "quality concern flagged after import: path={}", + dest.display() + ), + )?; + } + + Ok(ImportOutcome::Imported { + remuxed, + quality_flagged, + }) +} + +#[derive(Debug, Default)] +struct SeasonPackImportOutcome { + episodes_imported: usize, + episodes_already_had_better: usize, + episodes_unmatched: usize, + quality_flagged: usize, +} + +/// Imports a season-pack/batch torrent: unlike `import_one` (which resolves +/// to exactly one destination file), a pack's `content_path` is typically a +/// directory holding one file per episode. Each file is matched to its own +/// episode independently by re-parsing *its own filename* — batch releases +/// almost always name each inner file with its own SxxExx marker even +/// though the outer torrent name doesn't (that's precisely what made it +/// unparseable as a single episode in the first place, see +/// `looks_like_season_pack`). A file that can't be matched to a tracked +/// episode is skipped, not fatal to the rest of the pack. +/// +/// Deliberately does not run the mkv English-audio-default remux fix +/// `import_one` applies inline — doing that per-file here would roughly +/// double a season pack's import time. Any file that needs it is still +/// reachable afterward via `remux_backlog`, which sweeps the whole library +/// including season-pack imports. +fn import_season_pack( + conn: &Connection, + release_id: i64, + media_item_id: i64, + series_title: &str, + season_number: u32, + root_folder: &str, + content_path: &Path, +) -> Result { + let release_score: f32 = conn + .query_row( + "SELECT score FROM release WHERE id = ?1", + params![release_id], + |row| row.get::<_, Option>(0), + )? + .unwrap_or(0.0); + + let candidates = if content_path.is_file() { + vec![content_path.to_path_buf()] + } else { + walk_files(content_path)? + }; + let video_files: Vec = candidates + .into_iter() + .filter(|p| { + p.extension() + .and_then(|e| e.to_str()) + .map(|e| VIDEO_EXTS.contains(&e.to_lowercase().as_str())) + .unwrap_or(false) + }) + .collect(); + if video_files.is_empty() { + anyhow::bail!("no video files found under {}", content_path.display()); + } + + std::fs::create_dir_all(root_folder)?; + + let mut outcome = SeasonPackImportOutcome::default(); + for source_path in &video_files { + let filename_only = source_path + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or_default(); + let parsed = crate::parser::parse(filename_only); + let Some(episode_number) = parsed.episode.or(parsed.absolute_episode) else { + outcome.episodes_unmatched += 1; + tracing::warn!( + file = filename_only, + "season pack: could not determine an episode number for this file, skipping" + ); + continue; + }; + // Prefer the individual file's own season marker when it has one + // (a pack can occasionally mix seasons); fall back to the pack's + // own season otherwise. + let file_season = parsed.season.unwrap_or(season_number); + + let episode_id = match crate::scheduler::find_episode_id( + conn, + media_item_id, + file_season, + episode_number, + )? { + Some(id) => id, + None => { + outcome.episodes_unmatched += 1; + tracing::warn!( + file = filename_only, + season = file_season, + episode = episode_number, + "season pack: no tracked episode matches this file, skipping" + ); + continue; + } + }; + + match import_season_pack_file( + conn, + release_id, + release_score, + media_item_id, + episode_id, + series_title, + file_season, + episode_number, + root_folder, + source_path, + ) { + Ok(PackFileOutcome::Imported { quality_flagged }) => { + outcome.episodes_imported += 1; + if quality_flagged { + outcome.quality_flagged += 1; + } + } + Ok(PackFileOutcome::SkippedAlreadyHaveBetter) => { + outcome.episodes_already_had_better += 1; + } + Err(e) => { + outcome.episodes_unmatched += 1; + tracing::warn!( + file = filename_only, + error = %e, + "season pack: failed to import this file, continuing with the rest of the pack" + ); + } + } + } + + if outcome.episodes_imported > 0 { + conn.execute( + "UPDATE release SET status = 'imported' WHERE id = ?1", + params![release_id], + )?; + crate::db::record_event( + conn, + media_item_id, + None, + "imported", + &format!( + "season pack S{season_number:02}: {} imported, {} already had a better file, {} unmatched", + outcome.episodes_imported, outcome.episodes_already_had_better, outcome.episodes_unmatched + ), + )?; + } else if outcome.episodes_already_had_better > 0 { + // Mirrors import_one's single-file "SkippedAlreadyHaveBetter" -> + // 'upgraded' handling: nothing new landed, but that's because the + // whole pack was a no-op upgrade attempt, not a failure. + conn.execute( + "UPDATE release SET status = 'upgraded' WHERE id = ?1", + params![release_id], + )?; + } else { + // Every file was unmatched and nothing was already-better either — + // genuinely wrong (mismatched season, mislabeled pack). Propagating + // this as an error routes it through the same retry/escalate-to- + // failed machinery `process_pending_grabs` already applies to a + // persistently failing single-file import. + anyhow::bail!( + "season pack import matched none of {} video file(s) to a tracked episode", + video_files.len() + ); + } + + Ok(outcome) +} + +enum PackFileOutcome { + Imported { quality_flagged: bool }, + SkippedAlreadyHaveBetter, +} + +/// One file's worth of the season-pack import: the same dest-collision +/// scoring, free-space check, hardlink-or-copy, `episode_file` bookkeeping, +/// and post-import probe that `import_one` does for a single-episode grab, +/// scoped to one already-identified `episode_id` within a larger pack. +#[allow(clippy::too_many_arguments)] +fn import_season_pack_file( + conn: &Connection, + release_id: i64, + release_score: f32, + media_item_id: i64, + episode_id: i64, + series_title: &str, + season_number: u32, + episode_number: u32, + root_folder: &str, + source_path: &Path, +) -> Result { + let ext = source_path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("mkv") + .to_string(); + let episode_title: Option = conn.query_row( + "SELECT title FROM episode WHERE id = ?1", + params![episode_id], + |row| row.get(0), + )?; + let filename = deterministic_filename( + series_title, + season_number, + episode_number, + episode_title.as_deref(), + &ext, + ); + let dest = Path::new(root_folder).join(&filename); + + // Same reasoning as import_one's own dest-collision check: a second + // release for the same episode (here, from a *different* pack or a + // single-episode grab) must not silently overwrite a better file + // already in place. + if dest.exists() { + let existing_best: Option = conn.query_row( + "SELECT MAX(score) FROM release WHERE status = 'imported' AND id != ?1 AND episode_id = ?2", + params![release_id, episode_id], + |row| row.get(0), + )?; + if existing_best.is_some_and(|best| best >= release_score) { + return Ok(PackFileOutcome::SkippedAlreadyHaveBetter); + } + // This episode's file is being upgraded — clear the stale row and + // file first so the hardlink below is a real hardlink swap rather + // than a copy-fallback overwrite, and so no duplicate episode_file + // row survives. See import_one's matching comment for the fuller + // reasoning. + conn.execute( + "DELETE FROM episode_file WHERE path = ?1", + params![dest.to_string_lossy()], + )?; + std::fs::remove_file(&dest).ok(); + } + + let needed_bytes = std::fs::metadata(source_path)?.len(); + if insufficient_space(Path::new(root_folder), needed_bytes)? { + anyhow::bail!( + "not enough free space at {root_folder} for {needed_bytes} bytes (source: {})", + source_path.display() + ); + } + + link_or_copy_file(source_path, &dest)?; + + let size_bytes = std::fs::metadata(&dest)?.len(); + conn.execute( + "INSERT INTO episode_file (episode_id, media_item_id, path, size_bytes, subtitle_status) VALUES (?1, ?2, ?3, ?4, 'none')", + params![episode_id, media_item_id, dest.to_string_lossy(), size_bytes], + )?; + let episode_file_id = conn.last_insert_rowid(); + conn.execute( + "UPDATE episode SET has_file = 1 WHERE id = ?1", + params![episode_id], + )?; + + let quality_flagged = match ensure_probed(conn, episode_file_id, &dest) { + Ok(_) => probe_indicates_import_problem(conn, episode_file_id)?, + Err(e) => { + tracing::warn!(episode_file_id, error = %e, "post-import probing failed"); + false + } + }; + + Ok(PackFileOutcome::Imported { quality_flagged }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Generates a tiny real video via ffmpeg's `lavfi` synthetic source — + /// gives `ensure_probed`/`import_one` a real file to run actual ffprobe + /// against, rather than only exercising the DB/flag-computation logic + /// against hand-built fixtures. + fn generate_test_clip(dir: &Path, width: u32, height: u32) -> PathBuf { + let path = dir.join("clip.mkv"); + let status = std::process::Command::new("ffmpeg") + .args(["-y", "-f", "lavfi", "-i"]) + .arg(format!("testsrc=size={width}x{height}:duration=1:rate=1")) + .args(["-c:v", "libx264"]) + .arg(&path) + .output() + .expect("failed to run ffmpeg to generate a test clip"); + assert!( + status.status.success(), + "ffmpeg failed to generate test clip: {}", + String::from_utf8_lossy(&status.stderr) + ); + path + } + + #[test] + fn ensure_probed_flags_a_low_resolution_file_as_under_quality() { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (1, NULL, 1, '/tmp/placeholder.mkv', 4, 'none')", + [], + ) + .unwrap(); + + let dir = + std::env::temp_dir().join(format!("breadarr-ensure-probed-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let clip = generate_test_clip(&dir, 640, 360); + + let probed = ensure_probed(&conn, 1, &clip).unwrap(); + assert!(probed, "first probe of a file should always run"); + + let (height, under_quality, status): (Option, i64, String) = conn + .query_row( + "SELECT height, flag_under_quality, corruption_status FROM media_file_probe WHERE episode_file_id = 1", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); + assert_eq!(height, Some(360)); + assert_eq!(under_quality, 1); + assert_eq!(status, "probe_ok"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// A wider-than-16:9 master (2.00:1 is common for prestige/streaming + /// shows — verified live against a real "For All Mankind" episode at + /// exactly this resolution) has full 1920px width but under-1080 + /// height purely from the aspect ratio, not from being a worse encode. + /// A height-only check flagged 49 real episodes of one show as + /// under-quality before this was caught — this file must NOT be + /// flagged, since its width clears the 1080p-class bar even though its + /// height doesn't. + #[test] + fn ensure_probed_does_not_flag_a_wide_aspect_ratio_file_as_under_quality() { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (1, NULL, 1, '/tmp/placeholder.mkv', 4, 'none')", + [], + ) + .unwrap(); + + let dir = std::env::temp_dir().join(format!( + "breadarr-ensure-probed-wide-aspect-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let clip = generate_test_clip(&dir, 1920, 960); + + ensure_probed(&conn, 1, &clip).unwrap(); + + let (width, height, under_quality): (Option, Option, i64) = conn + .query_row( + "SELECT width, height, flag_under_quality FROM media_file_probe WHERE episode_file_id = 1", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); + assert_eq!(width, Some(1920)); + assert_eq!(height, Some(960)); + assert_eq!(under_quality, 0); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn ensure_probed_skips_reprobing_an_unchanged_file() { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (1, NULL, 1, '/tmp/placeholder.mkv', 4, 'none')", + [], + ) + .unwrap(); + + let dir = std::env::temp_dir().join(format!( + "breadarr-ensure-probed-skip-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let clip = generate_test_clip(&dir, 640, 360); + + assert!(ensure_probed(&conn, 1, &clip).unwrap()); + assert!( + !ensure_probed(&conn, 1, &clip).unwrap(), + "second probe against the exact same file content should be a no-op" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn probe_library_reports_probed_vs_skipped_up_to_date() { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')", + [], + ) + .unwrap(); + + let dir = + std::env::temp_dir().join(format!("breadarr-probe-library-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let clip = generate_test_clip(&dir, 640, 360); + conn.execute( + "INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (1, NULL, 1, ?1, 4, 'none')", + params![clip.to_string_lossy()], + ) + .unwrap(); + + let first = probe_library(&conn).unwrap(); + assert_eq!(first.probed, 1); + assert_eq!(first.skipped_up_to_date, 0); + + let second = probe_library(&conn).unwrap(); + assert_eq!(second.probed, 0); + assert_eq!(second.skipped_up_to_date, 1); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn verify_library_confirms_a_genuinely_decodable_file() { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (1, NULL, 1, '/tmp/placeholder.mkv', 4, 'none')", + [], + ) + .unwrap(); + + let dir = + std::env::temp_dir().join(format!("breadarr-verify-library-ok-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let clip = generate_test_clip(&dir, 640, 360); + conn.execute( + "UPDATE episode_file SET path = ?1 WHERE id = 1", + params![clip.to_string_lossy()], + ) + .unwrap(); + // `ensure_probed` leaves a freshly-probed, never-decode-checked file + // at exactly the `corruption_status = 'probe_ok'` state + // `verify_library` targets. + ensure_probed(&conn, 1, &clip).unwrap(); + + let report = verify_library(&conn).unwrap(); + assert_eq!(report.verified_ok, 1); + assert_eq!(report.corrupt, 0); + assert_eq!(report.errors, 0); + + let status: String = conn + .query_row( + "SELECT corruption_status FROM media_file_probe WHERE episode_file_id = 1", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(status, "decode_ok"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn verify_library_flags_a_file_that_parses_but_does_not_decode() { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')", + [], + ) + .unwrap(); + + let dir = std::env::temp_dir().join(format!( + "breadarr-verify-library-bad-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + // A file with an .mkv extension but garbage content: `ffprobe`'s + // cheap header check may or may not accept it, but the point here + // is simulating a row that's already `probe_ok` (however it got + // there) and confirming the full decode check catches what the + // header probe missed. + let bad_path = dir.join("bad.mkv"); + std::fs::write(&bad_path, b"not a real matroska file").unwrap(); + + conn.execute( + "INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (1, NULL, 1, ?1, 4, 'none')", + params![bad_path.to_string_lossy()], + ) + .unwrap(); + conn.execute( + "INSERT INTO media_file_probe (episode_file_id, probed_at, probe_size_bytes, probe_mtime, corruption_status) + VALUES (1, datetime('now'), 4, 0, 'probe_ok')", + [], + ) + .unwrap(); + + let report = verify_library(&conn).unwrap(); + assert_eq!(report.verified_ok, 0); + assert_eq!(report.corrupt, 1); + assert_eq!(report.errors, 0); + + let status: String = conn + .query_row( + "SELECT corruption_status FROM media_file_probe WHERE episode_file_id = 1", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(status, "decode_failed"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn verify_library_skips_files_that_never_even_passed_the_header_probe() { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (1, NULL, 1, '/tmp/nonexistent.mkv', 4, 'none')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO media_file_probe (episode_file_id, probed_at, probe_size_bytes, probe_mtime, corruption_status) + VALUES (1, datetime('now'), 4, 0, 'probe_failed')", + [], + ) + .unwrap(); + + let report = verify_library(&conn).unwrap(); + assert_eq!(report.verified_ok, 0); + assert_eq!(report.corrupt, 0); + assert_eq!(report.errors, 0); + } + + /// Seeds a movie release with `grabbed_at` shifted `hours_ago` into the + /// past, so stall/grace-period thresholds can be tested deterministically + /// instead of depending on real wall-clock time passing. + fn seeded_release_conn(hours_ago: f64) -> (Connection, i64) { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, status, torrent_hash, grabbed_at) + VALUES (1, 1, NULL, 'Some Movie 2016', 1, 'guid-1', 'grabbed', 'deadbeef', datetime('now', ?1))", + params![format!("-{hours_ago} hours")], + ) + .unwrap(); + (conn, 1) + } + + /// A dedicated root_folder per row (rather than one shared dir with many + /// rows in it) so `RECONCILE_MIN_CLEAR_FLOOR` doesn't mask the specific + /// count each of these small tests is asserting on. + fn media_item_with_root(conn: &Connection, id: i64, root: &std::path::Path) { + std::fs::create_dir_all(root).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (?1, 'series', 'Some Show', 2020, 1, 1, ?2)", + params![id, root.to_string_lossy()], + ) + .unwrap(); + } + + #[test] + fn reconcile_clears_episode_file_rows_whose_path_no_longer_exists() { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + let dir = std::env::temp_dir().join(format!("breadarr-reconcile-{}", std::process::id())); + media_item_with_root(&conn, 1, &dir); + conn.execute( + "INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file) + VALUES (1, 1, 1, 1, 1, 1)", + [], + ) + .unwrap(); + + let existing = dir.join("exists.mkv"); + std::fs::write(&existing, b"data").unwrap(); + let missing = dir.join("gone.mkv"); // deliberately never created, and + // no same-named file anywhere under `dir` either + + conn.execute( + "INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (1, 1, 1, ?1, 4, 'none')", + params![existing.to_string_lossy()], + ) + .unwrap(); + // episode_id NULL simulates a movie's file going missing. + conn.execute( + "INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (2, NULL, 1, ?1, 4, 'none')", + params![missing.to_string_lossy()], + ) + .unwrap(); + + let outcome = reconcile_missing_files(&conn, false).unwrap(); + assert_eq!(outcome.cleared, 1); + assert_eq!(outcome.repaired, 0); + assert!(!outcome.aborted); + + let remaining: i64 = conn + .query_row("SELECT count(*) FROM episode_file", [], |r| r.get(0)) + .unwrap(); + assert_eq!(remaining, 1, "only the still-existing file survives"); + + let has_file: i64 = conn + .query_row("SELECT has_file FROM episode WHERE id = 1", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(has_file, 1, "untouched — its own file still exists"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn reconcile_repairs_a_path_whose_show_folder_was_renamed_instead_of_clearing_it() { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + let root = + std::env::temp_dir().join(format!("breadarr-reconcile-repair-{}", std::process::id())); + // Simulates the folder-normalization case: `root_folder` already + // reflects the renamed-to-include-year directory, but the stored + // `episode_file.path` still points at the old (pre-rename) name. + let renamed_show_dir = root.join("Black Adder (1983)").join("Season 1"); + std::fs::create_dir_all(&renamed_show_dir).unwrap(); + let real_file = renamed_show_dir.join("Blackadder - S01E04.mkv"); + std::fs::write(&real_file, b"data").unwrap(); + + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (1, 'series', 'Black Adder', 1983, 1, 1, ?1)", + params![root.join("Black Adder (1983)").to_string_lossy()], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file) + VALUES (1, 1, 1, 4, 1, 1)", + [], + ) + .unwrap(); + let stale_path = root + .join("Black Adder") // missing the "(1983)" suffix + .join("Season 1") + .join("Blackadder - S01E04.mkv"); + conn.execute( + "INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (1, 1, NULL, ?1, 4, 'none')", + params![stale_path.to_string_lossy()], + ) + .unwrap(); + + let outcome = reconcile_missing_files(&conn, false).unwrap(); + assert_eq!(outcome.repaired, 1); + assert_eq!(outcome.cleared, 0); + assert!(!outcome.aborted); + + let new_path: String = conn + .query_row("SELECT path FROM episode_file WHERE id = 1", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(new_path, real_file.to_string_lossy()); + + let has_file: i64 = conn + .query_row("SELECT has_file FROM episode WHERE id = 1", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(has_file, 1, "repaired, not cleared — file genuinely exists"); + + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn reconcile_repairs_a_path_whose_extension_changed_from_a_transcode() { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + let root = std::env::temp_dir().join(format!( + "breadarr-reconcile-transcode-{}", + std::process::id() + )); + let movie_dir = root.join("Upgrade (2018)"); + std::fs::create_dir_all(&movie_dir).unwrap(); + // Simulates Tdarr re-encoding in place: the original .mp4 is gone, + // replaced by a .mkv with the same stem. + let real_file = movie_dir.join("Upgrade (2018).mkv"); + std::fs::write(&real_file, b"data").unwrap(); + + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (1, 'movie', 'Upgrade', 2018, 1, 1, ?1)", + params![movie_dir.to_string_lossy()], + ) + .unwrap(); + let stale_path = movie_dir.join("Upgrade (2018).mp4"); + conn.execute( + "INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (1, NULL, 1, ?1, 4, 'none')", + params![stale_path.to_string_lossy()], + ) + .unwrap(); + + let outcome = reconcile_missing_files(&conn, false).unwrap(); + assert_eq!(outcome.repaired, 1); + assert_eq!(outcome.cleared, 0); + assert!(!outcome.aborted); + + let new_path: String = conn + .query_row("SELECT path FROM episode_file WHERE id = 1", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(new_path, real_file.to_string_lossy()); + + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn reconcile_refuses_to_clear_an_anomalous_fraction_in_one_pass() { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + let root = + std::env::temp_dir().join(format!("breadarr-reconcile-breaker-{}", std::process::id())); + media_item_with_root(&conn, 1, &root); + + // 20 rows, all genuinely missing (no matching basename anywhere) — + // comfortably past both the 10% fraction and the floor of 10. + for i in 0..20 { + conn.execute( + "INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file) + VALUES (?1, 1, 1, ?1, 1, 1)", + params![i], + ) + .unwrap(); + let path = root.join(format!("gone-{i}.mkv")); + conn.execute( + "INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (?1, ?1, NULL, ?2, 4, 'none')", + params![i, path.to_string_lossy()], + ) + .unwrap(); + } + + let outcome = reconcile_missing_files(&conn, false).unwrap(); + assert!(outcome.aborted); + assert_eq!(outcome.cleared, 0); + assert_eq!(outcome.repaired, 0); + + let remaining: i64 = conn + .query_row("SELECT count(*) FROM episode_file", [], |r| r.get(0)) + .unwrap(); + assert_eq!(remaining, 20, "nothing cleared once the breaker trips"); + + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn reconcile_dry_run_reports_without_writing_anything() { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + let dir = + std::env::temp_dir().join(format!("breadarr-reconcile-dryrun-{}", std::process::id())); + media_item_with_root(&conn, 1, &dir); + let missing = dir.join("gone.mkv"); + conn.execute( + "INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (1, NULL, 1, ?1, 4, 'none')", + params![missing.to_string_lossy()], + ) + .unwrap(); + + let outcome = reconcile_missing_files(&conn, true).unwrap(); + assert_eq!(outcome.cleared, 1); + + let remaining: i64 = conn + .query_row("SELECT count(*) FROM episode_file", [], |r| r.get(0)) + .unwrap(); + assert_eq!(remaining, 1, "dry run must not actually delete the row"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_fresh_grab_is_not_stalled() { + let (conn, release_id) = seeded_release_conn(1.0); + assert!(!grab_is_stalled(&conn, release_id).unwrap()); + } + + #[test] + fn a_grab_untouched_past_the_threshold_is_stalled() { + let (conn, release_id) = seeded_release_conn(STALL_THRESHOLD_HOURS + 1.0); + assert!(grab_is_stalled(&conn, release_id).unwrap()); + } + + #[test] + fn progress_advancing_resets_the_stall_clock() { + let (conn, release_id) = seeded_release_conn(STALL_THRESHOLD_HOURS + 1.0); + // grabbed_at is old, but progress was just observed advancing, so + // last_progress_at (which the stall check prefers) is fresh. + update_grab_progress(&conn, release_id, 0.5).unwrap(); + assert!(!grab_is_stalled(&conn, release_id).unwrap()); + } + + #[test] + fn update_grab_progress_ignores_a_non_increasing_value() { + let (conn, release_id) = seeded_release_conn(0.0); + update_grab_progress(&conn, release_id, 0.5).unwrap(); + let first_seen: String = conn + .query_row( + "SELECT last_progress_at FROM release WHERE id = ?1", + params![release_id], + |r| r.get(0), + ) + .unwrap(); + // Same progress again shouldn't touch the watermark timestamp. + update_grab_progress(&conn, release_id, 0.5).unwrap(); + let second_seen: String = conn + .query_row( + "SELECT last_progress_at FROM release WHERE id = ?1", + params![release_id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(first_seen, second_seen); + } + + #[test] + fn a_torrent_missing_within_the_grace_period_is_not_yet_failed() { + let (conn, release_id) = seeded_release_conn(0.0); + assert!(!grab_missing_past_grace(&conn, release_id).unwrap()); + } + + #[test] + fn a_torrent_missing_past_the_grace_period_is_failed() { + let (conn, release_id) = seeded_release_conn(MISSING_GRACE_MINUTES / 60.0 + 1.0); + assert!(grab_missing_past_grace(&conn, release_id).unwrap()); + } + + #[test] + fn fail_grab_reopens_the_release_for_search() { + let (conn, release_id) = seeded_release_conn(0.0); + let grab = PendingGrab::Movie { + release_id, + media_item_id: 1, + torrent_hash: "deadbeef".to_string(), + title: "Some Movie".to_string(), + year: Some(2016), + root_folder: "/tmp".to_string(), + }; + fail_grab(&conn, &grab, "test").unwrap(); + let status: String = conn + .query_row( + "SELECT status FROM release WHERE id = ?1", + params![release_id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(status, "failed"); + + let (event_type, detail): (String, String) = conn + .query_row( + "SELECT event_type, detail FROM event_history WHERE media_item_id = 1", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(event_type, "failed"); + assert_eq!(detail, "test"); + } + + #[test] + fn builds_filename_with_episode_title() { + assert_eq!( + deterministic_filename("Some Show", 4, 13, Some("Winter Gathering"), "mkv"), + "Some Show - S04E13 - Winter Gathering.mkv" + ); + } + + #[test] + fn builds_filename_without_episode_title() { + assert_eq!( + deterministic_filename("Some Show", 1, 1, None, "mkv"), + "Some Show - S01E01.mkv" + ); + } + + #[test] + fn sanitizes_path_hostile_characters() { + assert_eq!(sanitize("Kill: Ao / Blue?"), "Kill_ Ao _ Blue_"); + } + + #[test] + fn nearest_existing_ancestor_returns_the_path_itself_when_it_exists() { + let dir = std::env::temp_dir(); + assert_eq!(nearest_existing_ancestor(&dir).unwrap(), dir); + } + + #[test] + fn nearest_existing_ancestor_walks_up_past_nonexistent_components() { + let dir = std::env::temp_dir().join(format!( + "breadarr-ancestor-test-{}/does/not/exist/yet", + std::process::id() + )); + let expected = + std::env::temp_dir().join(format!("breadarr-ancestor-test-{}", std::process::id())); + std::fs::create_dir_all(&expected).unwrap(); + assert_eq!(nearest_existing_ancestor(&dir).unwrap(), expected); + std::fs::remove_dir_all(&expected).unwrap(); + } + + #[test] + fn staging_dir_for_is_named_by_torrent_hash_under_the_marker_directory() { + let dest = std::env::temp_dir(); + let staging = staging_dir_for(&dest, "deadbeef1234").unwrap(); + assert_eq!( + staging.file_name().unwrap().to_str().unwrap(), + "deadbeef1234" + ); + assert_eq!( + staging + .parent() + .unwrap() + .file_name() + .unwrap() + .to_str() + .unwrap(), + STAGING_DIR_NAME + ); + } + + #[test] + fn insufficient_space_is_false_for_a_trivially_small_request() { + assert!(!insufficient_space(&std::env::temp_dir(), 1).unwrap()); + } + + #[test] + fn insufficient_space_is_true_for_an_absurd_request() { + // No real filesystem has an exabyte free. + assert!(insufficient_space(&std::env::temp_dir(), u64::MAX / 2).unwrap()); + } + + #[test] + fn copy_via_temp_file_writes_through_a_part_file_and_renames_into_place() { + let dir = std::env::temp_dir().join(format!("breadarr-copy-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let src = dir.join("source.mkv"); + std::fs::write(&src, b"fake video data").unwrap(); + let dest = dir.join("dest.mkv"); + + copy_via_temp_file(&src, &dest).unwrap(); + + assert!(dest.exists()); + assert!( + !dir.join("dest.mkv.part").exists(), + "the .part file should be renamed away, not left behind" + ); + assert!(src.exists(), "the copy fallback should also preserve src"); + assert_eq!(std::fs::read(&src).unwrap(), std::fs::read(&dest).unwrap()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn link_or_copy_file_leaves_the_source_in_place() { + let dir = std::env::temp_dir().join(format!("breadarr-link-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let src = dir.join("source.mkv"); + std::fs::write(&src, b"fake video data").unwrap(); + let dest = dir.join("dest.mkv"); + + link_or_copy_file(&src, &dest).unwrap(); + + assert!(src.exists(), "source should survive a hardlink-or-copy"); + assert!(dest.exists()); + assert_eq!(std::fs::read(&src).unwrap(), std::fs::read(&dest).unwrap()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn locates_a_single_file_torrent() { + let dir = std::env::temp_dir().join(format!("breadarr-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("episode.mkv"); + std::fs::write(&file, b"fake video data").unwrap(); + + let found = locate_video_file(&file).unwrap(); + assert_eq!(found, file); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn remap_path_translates_container_prefix_to_host_prefix() { + assert_eq!( + remap_path( + "/downloads/Big Buck Bunny", + "/downloads", + "/home/breadway/downloads" + ), + PathBuf::from("/home/breadway/downloads/Big Buck Bunny") + ); + } + + #[test] + fn remap_path_is_noop_when_prefixes_not_configured() { + assert_eq!( + remap_path("/downloads/Big Buck Bunny", "", ""), + PathBuf::from("/downloads/Big Buck Bunny") + ); + } + + #[test] + fn process_pending_grabs_routes_each_grab_by_torrent_state() { + use crate::qbit::TorrentInfo; + + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')", + [], + ) + .unwrap(); + + let dir = + std::env::temp_dir().join(format!("breadarr-process-grabs-{}", std::process::id())); + let dest_root = dir.join("library"); + std::fs::create_dir_all(&dest_root).unwrap(); + + // Grab 1: complete, should import. + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (1, 'movie', 'Complete Movie', 2020, 1, 1, ?1)", + params![dest_root.to_string_lossy()], + ) + .unwrap(); + conn.execute( + "INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, status, torrent_hash, grabbed_at) + VALUES (1, 1, NULL, 'Complete Movie 2020', 1, 'guid-1', 'grabbed', 'hash-complete', datetime('now'))", + [], + ) + .unwrap(); + let complete_content = dir.join("complete-content"); + std::fs::create_dir_all(&complete_content).unwrap(); + std::fs::write(complete_content.join("movie.mp4"), b"data").unwrap(); + + // Grab 2: still downloading, should be skipped as incomplete. + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (2, 'movie', 'Downloading Movie', 2020, 1, 1, ?1)", + params![dest_root.to_string_lossy()], + ) + .unwrap(); + conn.execute( + "INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, status, torrent_hash, grabbed_at) + VALUES (2, 2, NULL, 'Downloading Movie 2020', 1, 'guid-2', 'grabbed', 'hash-downloading', datetime('now'))", + [], + ) + .unwrap(); + + // Grab 3: torrent hash absent from qBit's list, but well within the + // grace period — should not yet be failed. + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (3, 'movie', 'Fresh Grab', 2020, 1, 1, ?1)", + params![dest_root.to_string_lossy()], + ) + .unwrap(); + conn.execute( + "INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, status, torrent_hash, grabbed_at) + VALUES (3, 3, NULL, 'Fresh Grab 2020', 1, 'guid-3', 'grabbed', 'hash-missing-fresh', datetime('now'))", + [], + ) + .unwrap(); + + // Grab 4: torrent hash absent from qBit's list, grabbed long ago — + // should be marked failed. + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (4, 'movie', 'Abandoned Grab', 2020, 1, 1, ?1)", + params![dest_root.to_string_lossy()], + ) + .unwrap(); + conn.execute( + "INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, status, torrent_hash, grabbed_at) + VALUES (4, 4, NULL, 'Abandoned Grab 2020', 1, 'guid-4', 'grabbed', 'hash-missing-stale', datetime('now', '-1 hour'))", + [], + ) + .unwrap(); + + let pending = fetch_pending_grabs(&conn).unwrap(); + assert_eq!(pending.len(), 4); + + let torrents = vec![ + TorrentInfo { + hash: "hash-complete".to_string(), + name: "complete".to_string(), + state: "uploading".to_string(), + progress: 1.0, + save_path: dir.to_string_lossy().to_string(), + content_path: complete_content.to_string_lossy().to_string(), + }, + TorrentInfo { + hash: "hash-downloading".to_string(), + name: "downloading".to_string(), + state: "downloading".to_string(), + progress: 0.4, + save_path: dir.to_string_lossy().to_string(), + content_path: dir + .join("downloading-content") + .to_string_lossy() + .to_string(), + }, + // hash-missing-fresh and hash-missing-stale are deliberately + // absent — simulating torrents qBit no longer knows about. + ]; + + let stats = process_pending_grabs(&conn, &pending, &torrents, "", "").unwrap(); + + assert_eq!(stats.imported, 1); + assert_eq!(stats.skipped_incomplete, 1); + assert_eq!(stats.failed, 1); + assert_eq!(stats.errors, 0); + + let statuses: Vec<(i64, String)> = { + let mut stmt = conn + .prepare("SELECT id, status FROM release ORDER BY id") + .unwrap(); + stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?))) + .unwrap() + .collect::>() + .unwrap() + }; + assert_eq!(statuses[0], (1, "imported".to_string())); + assert_eq!(statuses[1], (2, "grabbed".to_string())); + assert_eq!(statuses[2], (3, "grabbed".to_string())); + assert_eq!(statuses[3], (4, "failed".to_string())); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn does_not_import_a_torrent_while_it_is_still_being_physically_moved() { + use crate::qbit::TorrentInfo; + + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (1, 'movie', 'Moving Movie', 2020, 1, 1, '/tmp')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, status, torrent_hash, grabbed_at) + VALUES (1, 1, NULL, 'Moving Movie 2020', 1, 'guid-1', 'grabbed', 'hash-moving', datetime('now'))", + [], + ) + .unwrap(); + + let pending = fetch_pending_grabs(&conn).unwrap(); + let torrents = vec![TorrentInfo { + hash: "hash-moving".to_string(), + name: "moving".to_string(), + // qBittorrent's own state for "setLocation relocation (or a + // manual move) still physically in flight" — content_path may + // already point at the destination while the bytes aren't + // fully there yet. + state: "moving".to_string(), + progress: 1.0, + save_path: "/tmp".to_string(), + content_path: "/tmp/somewhere-mid-move".to_string(), + }]; + + let stats = process_pending_grabs(&conn, &pending, &torrents, "", "").unwrap(); + assert_eq!(stats.imported, 0); + assert_eq!(stats.skipped_incomplete, 1); + assert_eq!(stats.errors, 0); + assert_eq!(stats.failed, 0); + + let status: String = conn + .query_row("SELECT status FROM release WHERE id = 1", [], |r| r.get(0)) + .unwrap(); + assert_eq!(status, "grabbed"); + } + + #[test] + fn a_persistently_failing_import_escalates_to_failed_instead_of_retrying_forever() { + use crate::qbit::TorrentInfo; + + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (1, 'movie', 'Broken Movie', 2020, 1, 1, '/tmp')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, status, torrent_hash, grabbed_at) + VALUES (1, 1, NULL, 'Broken Movie 2020', 1, 'guid-1', 'grabbed', 'hash-broken', datetime('now'))", + [], + ) + .unwrap(); + + let dir = std::env::temp_dir().join(format!( + "breadarr-persistent-import-error-{}", + std::process::id() + )); + // Deliberately empty — `locate_video_file` finds no video file + // here, so `import_one` fails deterministically every time, + // simulating a persistent real-world error (bad path remap, + // unreadable file) without needing to fake one out. + std::fs::create_dir_all(&dir).unwrap(); + + let torrents = vec![TorrentInfo { + hash: "hash-broken".to_string(), + name: "broken".to_string(), + state: "uploading".to_string(), + progress: 1.0, + save_path: dir.to_string_lossy().to_string(), + content_path: dir.to_string_lossy().to_string(), + }]; + + for i in 1..MAX_IMPORT_ERRORS { + let pending = fetch_pending_grabs(&conn).unwrap(); + let stats = process_pending_grabs(&conn, &pending, &torrents, "", "").unwrap(); + assert_eq!(stats.errors, 1, "iteration {i}"); + assert_eq!(stats.failed, 0, "iteration {i}"); + let status: String = conn + .query_row("SELECT status FROM release WHERE id = 1", [], |r| r.get(0)) + .unwrap(); + assert_eq!(status, "grabbed", "iteration {i}"); + } + + // The Nth failure crosses the threshold and gives up. + let pending = fetch_pending_grabs(&conn).unwrap(); + let stats = process_pending_grabs(&conn, &pending, &torrents, "", "").unwrap(); + assert_eq!(stats.failed, 1); + assert_eq!(stats.errors, 0); + let status: String = conn + .query_row("SELECT status FROM release WHERE id = 1", [], |r| r.get(0)) + .unwrap(); + assert_eq!(status, "failed"); + + // Failed releases are excluded from `fetch_pending_grabs`, so it + // stops being retried at all. + assert!(fetch_pending_grabs(&conn).unwrap().is_empty()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn imports_a_movie_release_with_no_episode_id() { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, status, torrent_hash, grabbed_at) + VALUES (1, 1, NULL, 'Some Movie 2016 1080p', 1, 'guid-1', 'grabbed', 'deadbeef', datetime('now'))", + [], + ) + .unwrap(); + + let dir = + std::env::temp_dir().join(format!("breadarr-movie-import-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let dest_root = dir.join("library"); + std::fs::create_dir_all(&dest_root).unwrap(); + let content = dir.join("Some.Movie.2016.1080p.mp4"); + std::fs::write(&content, b"fake movie data").unwrap(); + + let grab = PendingGrab::Movie { + release_id: 1, + media_item_id: 1, + torrent_hash: "deadbeef".to_string(), + title: "Some Movie".to_string(), + year: Some(2016), + root_folder: dest_root.to_string_lossy().to_string(), + }; + + let outcome = import_one(&conn, &grab, &content).unwrap(); + let ImportOutcome::Imported { remuxed, .. } = outcome else { + panic!("expected a real import, got a skip"); + }; + assert!(!remuxed); + + let dest = dest_root.join("Some Movie (2016).mp4"); + assert!(dest.exists(), "expected {} to exist", dest.display()); + assert!( + content.exists(), + "source file should survive import so qBittorrent can keep seeding" + ); + + let status: String = conn + .query_row("SELECT status FROM release WHERE id = 1", [], |r| r.get(0)) + .unwrap(); + assert_eq!(status, "imported"); + + let (episode_id, media_item_id): (Option, Option) = conn + .query_row( + "SELECT episode_id, media_item_id FROM episode_file WHERE path = ?1", + params![dest.to_string_lossy()], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(episode_id, None); + assert_eq!(media_item_id, Some(1)); + + let event_type: String = conn + .query_row( + "SELECT event_type FROM event_history WHERE media_item_id = 1", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(event_type, "imported"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn import_one_flags_quality_when_the_real_file_is_under_1080p() { + // Mirrors a real production case: a release whose title carries no + // resolution tag at all turned out, once ffprobed, to actually be + // SD — this is the exact gap the post-import ground-truth check + // closes (the pipeline previously only ever trusted claimed title + // text, never the real downloaded bytes). + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, status, torrent_hash, grabbed_at) + VALUES (1, 1, NULL, 'Some.Movie.WEB.H264-GROUP', 1, 'guid-1', 'grabbed', 'deadbeef', datetime('now'))", + [], + ) + .unwrap(); + + let dir = std::env::temp_dir().join(format!( + "breadarr-quality-flag-import-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let dest_root = dir.join("library"); + std::fs::create_dir_all(&dest_root).unwrap(); + let generated = generate_test_clip(&dir, 640, 360); + let content = dir.join("Some.Movie.WEB.H264-GROUP.mkv"); + std::fs::rename(&generated, &content).unwrap(); + + let grab = PendingGrab::Movie { + release_id: 1, + media_item_id: 1, + torrent_hash: "deadbeef".to_string(), + title: "Some Movie".to_string(), + year: Some(2016), + root_folder: dest_root.to_string_lossy().to_string(), + }; + + let outcome = import_one(&conn, &grab, &content).unwrap(); + let ImportOutcome::Imported { + quality_flagged, .. + } = outcome + else { + panic!("expected a real import, got a skip"); + }; + assert!( + quality_flagged, + "a genuinely sub-1080p file should be flagged even with an untagged title" + ); + + let flagged: i64 = conn + .query_row( + "SELECT flag_under_quality FROM media_file_probe WHERE episode_file_id = (SELECT id FROM episode_file LIMIT 1)", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(flagged, 1); + + let quality_events: i64 = conn + .query_row( + "SELECT count(*) FROM event_history WHERE media_item_id = 1 AND detail LIKE 'quality concern%'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(quality_events, 1); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Generates a real mkv with two audio tracks — a non-English one + /// flagged as default (track order 0), and English not-default (track + /// order 1) — so `remux_backlog` has a genuine "Italian track 1, + /// English track 2" case to fix, not just a hand-built fixture. + fn generate_dual_audio_clip(dir: &Path) -> PathBuf { + let path = dir.join("dual-audio.mkv"); + let status = std::process::Command::new("ffmpeg") + .args([ + "-y", + "-f", + "lavfi", + "-i", + "testsrc=size=1920x1080:duration=1:rate=1", + ]) + .args(["-f", "lavfi", "-i", "sine=frequency=440:duration=1"]) + .args(["-f", "lavfi", "-i", "sine=frequency=880:duration=1"]) + .args(["-map", "0:v", "-map", "1:a", "-map", "2:a"]) + .args([ + "-metadata:s:a:0", + "language=ita", + "-disposition:a:0", + "default", + ]) + .args(["-metadata:s:a:1", "language=eng", "-disposition:a:1", "0"]) + .args(["-c:v", "libx264", "-c:a", "aac"]) + .arg(&path) + .output() + .expect("failed to run ffmpeg to generate a dual-audio test clip"); + assert!( + status.status.success(), + "ffmpeg failed to generate dual-audio clip: {}", + String::from_utf8_lossy(&status.stderr) + ); + path + } + + #[test] + fn remux_backlog_promotes_english_to_default_for_a_flagged_file() { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')", + [], + ) + .unwrap(); + + let dir = + std::env::temp_dir().join(format!("breadarr-remux-backlog-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let clip = generate_dual_audio_clip(&dir); + + conn.execute( + "INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (1, NULL, 1, ?1, 4, 'none')", + params![clip.to_string_lossy()], + ) + .unwrap(); + // Probe it first so the flag actually gets set from real ffprobe + // output, exactly like it would in the running daemon. + ensure_probed(&conn, 1, &clip).unwrap(); + let flagged_before: i64 = conn + .query_row( + "SELECT flag_non_english_default_audio FROM media_file_probe WHERE episode_file_id = 1", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(flagged_before, 1, "fixture clip should start flagged"); + + let report = remux_backlog(&conn).unwrap(); + assert_eq!(report.remuxed, 1); + assert_eq!(report.errors, 0); + + let tracks = mkv::inspect_audio_tracks(&clip).unwrap(); + assert!( + mkv::default_track_is_english_or_unset(&tracks), + "the remuxed file's default audio track should now be English" + ); + + let flagged_after: i64 = conn + .query_row( + "SELECT flag_non_english_default_audio FROM media_file_probe WHERE episode_file_id = 1", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + flagged_after, 0, + "re-probe after remux should clear the flag" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn remux_backlog_skips_non_mkv_files() { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (1, NULL, 1, '/tmp/some-movie.mp4', 4, 'none')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO media_file_probe (episode_file_id, probed_at, probe_size_bytes, probe_mtime, + corruption_status, flag_under_quality, flag_no_subtitles, flag_no_english_audio, flag_non_english_default_audio) + VALUES (1, datetime('now'), 4, 0, 'probe_ok', 0, 0, 0, 1)", + [], + ) + .unwrap(); + + let report = remux_backlog(&conn).unwrap(); + assert_eq!(report.remuxed, 0); + assert_eq!(report.skipped_not_mkv, 1); + } + + #[test] + fn refuses_to_overwrite_an_already_imported_higher_scoring_file() { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')", + [], + ) + .unwrap(); + // The winner: already imported, high score. + conn.execute( + "INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, score, status, torrent_hash, grabbed_at) + VALUES (1, 1, NULL, 'Some Movie 2016 1080p', 1, 'guid-1', 20.0, 'imported', 'aaaa', datetime('now'))", + [], + ) + .unwrap(); + // The loser: a second, lower-scoring release for the same movie, + // now sitting completed in qBittorrent and up for import. + conn.execute( + "INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, score, status, torrent_hash, grabbed_at) + VALUES (2, 1, NULL, 'Some Movie 2016 480p', 1, 'guid-2', 5.0, 'grabbed', 'bbbb', datetime('now'))", + [], + ) + .unwrap(); + + let dir = + std::env::temp_dir().join(format!("breadarr-collision-import-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let dest_root = dir.join("library"); + std::fs::create_dir_all(&dest_root).unwrap(); + + // The winner's file already sits at the deterministic destination. + let dest = dest_root.join("Some Movie (2016).mp4"); + std::fs::write(&dest, b"the better file, already imported").unwrap(); + + let content = dir.join("Some.Movie.2016.480p.mp4"); + std::fs::write(&content, b"a worse duplicate arriving late").unwrap(); + + let grab = PendingGrab::Movie { + release_id: 2, + media_item_id: 1, + torrent_hash: "bbbb".to_string(), + title: "Some Movie".to_string(), + year: Some(2016), + root_folder: dest_root.to_string_lossy().to_string(), + }; + + let outcome = import_one(&conn, &grab, &content).unwrap(); + assert!(matches!(outcome, ImportOutcome::SkippedAlreadyHaveBetter)); + + // The existing better file must be untouched, not overwritten. + assert_eq!( + std::fs::read(&dest).unwrap(), + b"the better file, already imported" + ); + + let status: String = conn + .query_row("SELECT status FROM release WHERE id = 2", [], |r| r.get(0)) + .unwrap(); + assert_eq!(status, "upgraded"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_strictly_better_release_replaces_the_existing_file_via_a_real_hardlink_swap() { + use std::os::unix::fs::MetadataExt; + + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (1, 'movie', 'Some Movie', 2016, 1, 1, '/tmp')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')", + [], + ) + .unwrap(); + // The old, worse release: already imported, low score. + conn.execute( + "INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, score, status, torrent_hash, grabbed_at) + VALUES (1, 1, NULL, 'Some Movie 2016 480p', 1, 'guid-1', 5.0, 'imported', 'aaaa', datetime('now'))", + [], + ) + .unwrap(); + // The new, better release: sitting completed in qBittorrent, up for import. + conn.execute( + "INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, score, status, torrent_hash, grabbed_at) + VALUES (2, 1, NULL, 'Some Movie 2016 1080p', 1, 'guid-2', 20.0, 'grabbed', 'bbbb', datetime('now'))", + [], + ) + .unwrap(); + + let dir = + std::env::temp_dir().join(format!("breadarr-upgrade-swap-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let dest_root = dir.join("library"); + std::fs::create_dir_all(&dest_root).unwrap(); + + // The old, worse file already sits at the deterministic destination, + // tracked by its own episode_file row. + let dest = dest_root.join("Some Movie (2016).mp4"); + std::fs::write(&dest, b"the old, worse file").unwrap(); + conn.execute( + "INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (1, NULL, 1, ?1, 20, 'none')", + params![dest.to_string_lossy()], + ) + .unwrap(); + + let content = dir.join("Some.Movie.2016.1080p.mp4"); + std::fs::write(&content, b"the new, better file").unwrap(); + + let grab = PendingGrab::Movie { + release_id: 2, + media_item_id: 1, + torrent_hash: "bbbb".to_string(), + title: "Some Movie".to_string(), + year: Some(2016), + root_folder: dest_root.to_string_lossy().to_string(), + }; + + let outcome = import_one(&conn, &grab, &content).unwrap(); + assert!(matches!(outcome, ImportOutcome::Imported { .. })); + + // The new file's content landed at the shared deterministic path. + assert_eq!(std::fs::read(&dest).unwrap(), b"the new, better file"); + // It's a genuine hardlink to the source (same inode), not a copy — + // confirms the old file/row was cleared first so `hard_link` + // itself succeeded instead of falling back to `copy_via_temp_file`. + assert_eq!( + std::fs::metadata(&dest).unwrap().ino(), + std::fs::metadata(&content).unwrap().ino() + ); + + // Exactly one episode_file row survives for this movie — the old + // one was removed, not left behind as a duplicate alongside the new. + let file_count: i64 = conn + .query_row( + "SELECT count(*) FROM episode_file WHERE media_item_id = 1", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(file_count, 1); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn locates_the_largest_video_file_in_a_directory() { + let dir = std::env::temp_dir().join(format!("breadarr-test-dir-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("sample.mkv"), vec![0u8; 100]).unwrap(); + std::fs::write(dir.join("episode.mkv"), vec![0u8; 10_000]).unwrap(); + std::fs::write(dir.join("readme.txt"), b"not a video").unwrap(); + + let found = locate_video_file(&dir).unwrap(); + assert_eq!(found.file_name().unwrap(), "episode.mkv"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Seeds a series with `episode_count` episodes in season 1, all + /// monitored and missing, plus a `grabbed` season-pack release row. + /// Returns the media_item_id (always 1) and release_id (always 1). + fn seeded_season_pack_conn(episode_count: u32) -> Connection { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, year, monitored, quality_profile_id, root_folder) + VALUES (1, 'series', 'Some Show', 2020, 1, 1, '/tmp')", + [], + ) + .unwrap(); + for ep in 1..=episode_count { + conn.execute( + "INSERT INTO episode (media_item_id, season_number, episode_number, monitored, has_file) + VALUES (1, 1, ?1, 1, 0)", + params![ep], + ) + .unwrap(); + } + conn.execute( + "INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO release (id, media_item_id, episode_id, season_number, raw_title, source_id, guid, score, status, torrent_hash, grabbed_at) + VALUES (1, 1, NULL, 1, 'Some Show S01 Complete 1080p', 1, 'guid-1', 15.0, 'grabbed', 'aaaa', datetime('now'))", + [], + ) + .unwrap(); + conn + } + + #[test] + fn import_season_pack_imports_every_file_and_marks_episodes_owned() { + let conn = seeded_season_pack_conn(3); + let dir = std::env::temp_dir().join(format!("breadarr-season-pack-{}", std::process::id())); + let pack_dir = dir.join("Some.Show.S01.1080p.WEB-DL"); + std::fs::create_dir_all(&pack_dir).unwrap(); + for ep in 1..=3u32 { + std::fs::write( + pack_dir.join(format!("Some.Show.S01E{ep:02}.1080p.WEB-DL.mkv")), + format!("episode {ep} content"), + ) + .unwrap(); + } + let dest_root = dir.join("library"); + + let outcome = import_season_pack( + &conn, + 1, + 1, + "Some Show", + 1, + &dest_root.to_string_lossy(), + &pack_dir, + ) + .unwrap(); + + assert_eq!(outcome.episodes_imported, 3); + assert_eq!(outcome.episodes_already_had_better, 0); + assert_eq!(outcome.episodes_unmatched, 0); + + let has_file_count: i64 = conn + .query_row( + "SELECT count(*) FROM episode WHERE media_item_id = 1 AND has_file = 1", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(has_file_count, 3); + + let status: String = conn + .query_row("SELECT status FROM release WHERE id = 1", [], |r| r.get(0)) + .unwrap(); + assert_eq!(status, "imported"); + + assert!(dest_root.join("Some Show - S01E01.mkv").exists()); + assert!(dest_root.join("Some Show - S01E02.mkv").exists()); + assert!(dest_root.join("Some Show - S01E03.mkv").exists()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn import_season_pack_skips_episodes_that_already_have_a_better_file() { + let conn = seeded_season_pack_conn(2); + let dir = std::env::temp_dir().join(format!( + "breadarr-season-pack-partial-{}", + std::process::id() + )); + let pack_dir = dir.join("pack"); + std::fs::create_dir_all(&pack_dir).unwrap(); + std::fs::write(pack_dir.join("Show.S01E01.mkv"), b"new e01").unwrap(); + std::fs::write(pack_dir.join("Show.S01E02.mkv"), b"new e02").unwrap(); + let dest_root = dir.join("library"); + std::fs::create_dir_all(&dest_root).unwrap(); + + // Episode 1 already has a higher-scoring imported release. + conn.execute( + "INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, score, status, torrent_hash, grabbed_at) + VALUES (2, 1, 1, 'Some Show S01E01 1080p REMUX', 1, 'guid-2', 50.0, 'imported', 'bbbb', datetime('now'))", + [], + ) + .unwrap(); + let existing = dest_root.join("Some Show - S01E01.mkv"); + std::fs::write(&existing, b"already-better e01").unwrap(); + conn.execute( + "INSERT INTO episode_file (episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (1, NULL, ?1, 19, 'none')", + params![existing.to_string_lossy()], + ) + .unwrap(); + + let outcome = import_season_pack( + &conn, + 1, + 1, + "Some Show", + 1, + &dest_root.to_string_lossy(), + &pack_dir, + ) + .unwrap(); + + assert_eq!(outcome.episodes_imported, 1); // only episode 2 + assert_eq!(outcome.episodes_already_had_better, 1); // episode 1 skipped + assert_eq!( + std::fs::read(&existing).unwrap(), + b"already-better e01", + "episode 1's better file must survive untouched" + ); + assert!(dest_root.join("Some Show - S01E02.mkv").exists()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn import_season_pack_fails_when_nothing_in_it_matches_a_tracked_episode() { + let conn = seeded_season_pack_conn(2); + let dir = std::env::temp_dir().join(format!( + "breadarr-season-pack-nomatch-{}", + std::process::id() + )); + let pack_dir = dir.join("pack"); + std::fs::create_dir_all(&pack_dir).unwrap(); + // Wrong season entirely — nothing here matches season 1's episodes. + std::fs::write(pack_dir.join("Show.S02E01.mkv"), b"wrong season").unwrap(); + std::fs::write(pack_dir.join("Show.S02E02.mkv"), b"wrong season").unwrap(); + let dest_root = dir.join("library"); + + let result = import_season_pack( + &conn, + 1, + 1, + "Some Show", + 1, + &dest_root.to_string_lossy(), + &pack_dir, + ); + assert!(result.is_err()); + + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/breadarrd/src/jellyfin.rs b/breadarrd/src/jellyfin.rs new file mode 100644 index 0000000..eacb6a0 --- /dev/null +++ b/breadarrd/src/jellyfin.rs @@ -0,0 +1,41 @@ +use anyhow::{bail, Context, Result}; + +pub struct JellyfinClient { + base_url: String, + api_key: String, + client: reqwest::Client, +} + +impl JellyfinClient { + pub fn new(base_url: impl Into, api_key: impl Into) -> Self { + Self { + base_url: base_url.into(), + api_key: api_key.into(), + // No total timeout is reqwest's default — fine for a one-shot + // debug command, but the daemon's background loop holds the DB + // mutex across this call, so a stalled connection here would + // hang the whole daemon indefinitely. + client: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("reqwest client build"), + } + } + + pub async fn refresh_library(&self) -> Result<()> { + let resp = self + .client + .post(format!("{}/Library/Refresh", self.base_url)) + .header("X-Emby-Token", &self.api_key) + .send() + .await + .context("jellyfin library refresh request failed")?; + + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + bail!("jellyfin library refresh failed: status={status} body={body:?}"); + } + Ok(()) + } +} diff --git a/breadarrd/src/library_scan.rs b/breadarrd/src/library_scan.rs new file mode 100644 index 0000000..07db0ff --- /dev/null +++ b/breadarrd/src/library_scan.rs @@ -0,0 +1,861 @@ +use std::path::Path; +use std::sync::LazyLock; + +use anyhow::{Context, Result}; +use regex::Regex; +use rusqlite::{params, Connection, OptionalExtension}; + +use crate::importer; +use crate::matcher::TitleMatcher; +use crate::metadata::tmdb::TmdbClient; +use crate::metadata::tvdb::TvdbClient; +use crate::metadata::{self, MovieSearchResult, SeriesSearchResult}; +use crate::parser; + +#[derive(Debug, Default)] +pub struct ScanReport { + pub matched: Vec<(String, i64)>, + pub unmatched: Vec, + pub files_linked: usize, + pub files_renamed: usize, +} + +fn get_episode_title(conn: &Connection, episode_id: i64) -> Result> { + conn.query_row( + "SELECT title FROM episode WHERE id = ?1", + params![episode_id], + |row| row.get::<_, Option>(0), + ) + .map_err(Into::into) +} + +/// Renames `file` to `target_name` within the same directory (never moves +/// across directories/filesystems) — a plain filesystem rename, so it's +/// fast and doesn't touch file content. Refuses to clobber an existing, +/// differently-named file already at the target path. +fn rename_in_place(file: &Path, target_name: &str) -> Result { + let Some(parent) = file.parent() else { + return Ok(file.to_path_buf()); + }; + let target = parent.join(target_name); + if target == *file { + return Ok(file.to_path_buf()); + } + if target.exists() { + tracing::warn!( + from = %file.display(), + to = %target.display(), + "normalization target already exists, leaving file as-is" + ); + return Ok(file.to_path_buf()); + } + std::fs::rename(file, &target)?; + Ok(target) +} + +// A "[Group]" or "[Tag]" prefix at the very start (common anime release- +// group convention, e.g. "[SubsPlease] Show Name") — stripped before any +// other parsing so it doesn't pollute the metadata search query or survive +// into the normalized title. +static LEADING_BRACKET_TAG_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^\[[^\]]+\]\s*").unwrap()); +// " Movie 01 - " is a common anime-movie flat-file naming +// convention, but TMDB's search is not fuzzy enough to see past the "Movie +// 01" token — it returns zero results for the full string even though +// " " alone matches immediately. Only fires when a dash +// follows (an un-subtitled "Some Movie 2" is left alone). +static MOVIE_NUMBER_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)\bmovie\s*\d{1,3}\b\s*-\s*").unwrap()); +static FOLDER_BRACKET_YEAR_RE: LazyLock = + LazyLock::new(|| Regex::new(r"[(\[]((?:19|20)\d{2})[)\]]").unwrap()); +static FOLDER_BARE_YEAR_RE: LazyLock = + LazyLock::new(|| Regex::new(r"\b((?:19|20)\d{2})\b").unwrap()); +// Common release-tag vocabulary (quality/source/codec/audio/season markers) +// that shows up in folder names that were never cleaned up after being +// dropped straight out of a torrent client — everything from the first +// match onward is discarded, since normalization keeps only Name and Year. +static FOLDER_JUNK_RE: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?i)\b(1080p|720p|2160p|480p|4k|bluray|blu-ray|web-?dl|webrip|hdtv|dvdrip|remux|x264|x265|h\.?264|h\.?265|hevc|av1|dual[- ]?audio|multi[- ]?audio|dual|multi|proper|repack|extended|unrated|directors?\.?cut|10bit|8bit|complete|s\d{1,2}(?:e\d{1,3})?|season\s*\d+)\b", + ) + .unwrap() +}); + +fn clean_title_edge(s: &str) -> String { + s.trim() + .trim_end_matches(['-', ':', '(', '[']) + .trim() + .to_string() +} + +/// Extracts just Name and Year out of a folder name, discarding everything +/// else — release-tag cruft (resolution/source/codec/group/season markers) +/// that's meaningful on a torrent's own filename has no business surviving +/// into the library's folder layout. "Arrival (2016)" -> ("Arrival", +/// Some(2016)); "Arrival.2016.1080p.BluRay.x264-GROUP" -> ("Arrival", +/// Some(2016)); "Attack on Titan S01 1080p Dual Audio [Group]" -> ("Attack +/// on Titan", None); "Black Adder" -> ("Black Adder", None). +fn parse_folder_name(name: &str) -> (String, Option) { + let name = LEADING_BRACKET_TAG_RE.replace(name, ""); + let name = MOVIE_NUMBER_RE.replace(&name, ""); + let name = name.as_ref(); + + // Scene-style names pack everything into dot/underscore-separated + // tokens with no real spaces at all — normalize those to spaces before + // hunting for a year/junk boundary. Left untouched whenever real spaces + // are already present, so legitimately dotted titles ("Mr. Robot") + // aren't mangled. + let normalized = if !name.contains(' ') && (name.contains('.') || name.contains('_')) { + name.replace(['.', '_'], " ") + } else { + name.to_string() + }; + let normalized = parser::WS_RE + .replace_all(normalized.trim(), " ") + .to_string(); + + if let Some(c) = FOLDER_BRACKET_YEAR_RE.captures(&normalized) { + let year = c[1].parse().ok(); + let title = clean_title_edge(&normalized[..c.get(0).unwrap().start()]); + return (title, year); + } + // A bare year is only trusted as *the* year when something precedes it + // — otherwise a movie literally titled after a year ("1917", "2012") + // would have its own title mistaken for a year with nothing left over. + if let Some(m) = FOLDER_BARE_YEAR_RE.find(&normalized) { + if m.start() > 0 { + let year = normalized[m.start()..m.end()].parse().ok(); + let title = clean_title_edge(&normalized[..m.start()]); + return (title, year); + } + } + if let Some(m) = FOLDER_JUNK_RE.find(&normalized) { + return (clean_title_edge(&normalized[..m.start()]), None); + } + (normalized.trim().to_string(), None) +} + +fn canonical_folder_name(title: &str, year: Option) -> String { + match year { + Some(y) => format!("{} ({y})", importer::sanitize(title)), + None => importer::sanitize(title), + } +} + +/// Renames a media folder in place so its name is exactly the canonical +/// "{Title} ({Year})" form, discarding whatever release-tag/resolution/ +/// group cruft the original folder name carried — normalization is meant to +/// leave nothing but name and year behind. Same-filesystem rename, doesn't +/// touch file content; refuses to clobber an existing, different folder +/// already at the target name; a no-op if already canonical. +fn normalize_folder_name( + root: &Path, + current_dir: &Path, + title: &str, + year: Option, +) -> Result { + let target_dir = root.join(canonical_folder_name(title, year)); + if target_dir == *current_dir { + return Ok(current_dir.to_path_buf()); + } + if target_dir.exists() { + tracing::warn!( + from = %current_dir.display(), + to = %target_dir.display(), + "normalized folder name already exists, leaving folder as-is" + ); + return Ok(current_dir.to_path_buf()); + } + std::fs::rename(current_dir, &target_dir)?; + Ok(target_dir) +} + +fn subdirectories(root: &Path) -> Result> { + let mut entries: Vec<_> = std::fs::read_dir(root)? + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .collect(); + entries.sort_by_key(|e| e.file_name()); + Ok(entries) +} + +/// Below this cosine similarity, the "best" candidate among *multiple* +/// options still isn't good enough to trust over the alternatives — treated +/// as no match at all. Only applied when there's more than one candidate to +/// choose between: a lone candidate is accepted unconditionally, since a +/// low score there usually just means the provider has no English name or +/// alias for it at all (e.g. TVDB's only entry for a well-known show can be +/// its native-script title with non-English aliases), not that it's wrong. +const MIN_MATCH_CONFIDENCE: f32 = 0.5; + +/// TVDB/TMDB's own search relevance ranking isn't reliable enough to trust +/// blindly — e.g. searching "Attack on Titan" ranks the spinoff "Attack on +/// Titan: Counter Rockets" above the real 2013 series, whose own alias +/// list has the far-closer "Attack on Titan (2013)". Exact year match (when +/// the folder name has one) is tried first since it's a hard, cheap +/// signal; the embedding matcher — comparing the query against every +/// result's name *and* aliases, not just the top hit — breaks ties and +/// covers the (common) case where no year is available at all. +/// +/// When a year *is* known but no exact match exists, candidates whose year +/// is off by more than one are excluded before the embedding pass — without +/// this, a title-only match (a misplaced TV season-pack folder colliding +/// with an unrelated same-named movie already in the catalog) can look +/// identical to a real hit purely on text similarity. +fn pick_series_match( + matcher: &mut TitleMatcher, + query: &str, + results: Vec, + want_year: Option, +) -> Result> { + // No "exact year match wins outright" shortcut here on purpose — a + // year match alone doesn't disambiguate title (e.g. two same-year, + // differently-titled shows), and bypassing the embedding-similarity + // check below on year alone is the same incident class as the + // Naruto-Kai merge documented further down this file, except upstream + // of it: that guard only catches a bad match on *reuse* of an existing + // row, not on picking the wrong search result for a brand-new add. + // Year still narrows the candidate pool below, just never on its own. + let pool = match want_year { + Some(want) => year_filtered_pool(&results, want), + None => results.iter().collect::>(), + }; + if pool.is_empty() { + return Ok(None); + } + + let mut candidates = Vec::new(); + for (idx, r) in pool.iter().enumerate() { + candidates.push((idx, r.name.clone())); + for alias in &r.aliases { + candidates.push((idx, alias.clone())); + } + } + let Some((best_idx, score)) = matcher.best_match_index(query, &candidates)? else { + return Ok(None); + }; + // Gated on the *pre-filter* candidate count, not `pool.len()`: the year + // filter can take several wrong search results down to a single + // survivor that merely has a nearby year, which is exactly the kind of + // wrong-but-unopposed match this floor exists to catch. The single- + // candidate bypass is only safe when the provider's search itself + // returned one result (e.g. TVDB's only entry for a show has no English + // name/alias at all — still unambiguous), not when filtering produced one. + if results.len() > 1 && score < MIN_MATCH_CONFIDENCE { + return Ok(None); + } + Ok(pool.get(best_idx).map(|r| (*r).clone())) +} + +fn year_filtered_pool(results: &[T], want: u32) -> Vec<&T> +where + T: HasYear, +{ + results + .iter() + .filter(|r| r.year().is_none_or(|y| y.abs_diff(want) <= 1)) + .collect() +} + +trait HasYear { + fn year(&self) -> Option; +} + +impl HasYear for SeriesSearchResult { + fn year(&self) -> Option { + self.year + } +} + +impl HasYear for MovieSearchResult { + fn year(&self) -> Option { + self.year + } +} + +fn pick_movie_match( + matcher: &mut TitleMatcher, + query: &str, + results: Vec, + want_year: Option, +) -> Result> { + // See `pick_series_match` — no exact-year-wins-outright shortcut here + // either, for the same reason. + let pool = match want_year { + Some(want) => year_filtered_pool(&results, want), + None => results.iter().collect::>(), + }; + if pool.is_empty() { + return Ok(None); + } + + // TMDB movie results don't carry an alias list the way TVDB does, but + // running the same title through the matcher still catches the same + // class of "wrong entry ranked first" problem when titles are close + // but not identical (sequels, re-releases, regional retitles). + let candidates: Vec<(usize, String)> = pool + .iter() + .enumerate() + .map(|(idx, r)| (idx, r.title.clone())) + .collect(); + let Some((best_idx, score)) = matcher.best_match_index(query, &candidates)? else { + return Ok(None); + }; + // See `pick_series_match` — gated on the pre-filter count, not `pool`. + if results.len() > 1 && score < MIN_MATCH_CONFIDENCE { + return Ok(None); + } + Ok(pool.get(best_idx).map(|r| (*r).clone())) +} + +fn get_media_item_by_tvdb_id(conn: &Connection, tvdb_id: i64) -> Result> { + conn.query_row( + "SELECT id FROM media_item WHERE tvdb_id = ?1", + params![tvdb_id], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) +} + +fn get_media_item_by_tmdb_id(conn: &Connection, tmdb_id: i64) -> Result> { + conn.query_row( + "SELECT id FROM media_item WHERE tmdb_id = ?1 AND kind = 'movie'", + params![tmdb_id], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) +} + +/// Guards against blindly repointing an existing row's `root_folder` onto +/// whatever folder a provider-ID lookup happened to match, without ever +/// checking the two actually agree on *which show*. A provider ID can end +/// up on the wrong row for reasons entirely outside this function's control +/// (a bad selection when the show was originally added, a data-quality +/// slip on the provider's own end) — verified live: this exact gap let +/// "Boy Swallows Universe" (added with a TVDB ID that collided with Naruto +/// Kai's real one) silently absorb Naruto Kai's folder path on a later +/// scan, and Naruto Kai's own tracking row simply vanished, merged into an +/// unrelated show. Reuses the same embedding similarity the rest of the +/// matcher uses, so "close enough" here means the same thing it means +/// everywhere else in the app. +fn existing_row_title_plausibly_matches( + conn: &Connection, + matcher: &mut TitleMatcher, + media_item_id: i64, + candidate_title: &str, +) -> Result { + let existing_title: String = conn.query_row( + "SELECT title FROM media_item WHERE id = ?1", + params![media_item_id], + |row| row.get(0), + )?; + let score = matcher + .best_match_index(candidate_title, &[(0, existing_title.clone())])? + .map(|(_, score)| score) + .unwrap_or(0.0); + let plausible = score >= crate::matcher::MIN_CONFIDENCE; + if !plausible { + tracing::warn!( + media_item_id, + existing_title = %existing_title, + candidate_title, + score, + "provider-ID match found an existing row, but its title doesn't \ + plausibly match the folder being scanned — refusing to repoint \ + its root_folder onto a possibly-unrelated show" + ); + } + Ok(plausible) +} + +fn find_episode_id( + conn: &Connection, + media_item_id: i64, + season: u32, + episode: u32, +) -> Result> { + conn.query_row( + "SELECT id FROM episode WHERE media_item_id = ?1 AND season_number = ?2 AND episode_number = ?3", + params![media_item_id, season, episode], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) +} + +fn episode_has_file_row(conn: &Connection, episode_id: i64) -> Result { + let count: i64 = conn.query_row( + "SELECT count(*) FROM episode_file WHERE episode_id = ?1", + params![episode_id], + |row| row.get(0), + )?; + Ok(count > 0) +} + +fn movie_has_file_row(conn: &Connection, media_item_id: i64) -> Result { + let count: i64 = conn.query_row( + "SELECT count(*) FROM episode_file WHERE media_item_id = ?1 AND episode_id IS NULL", + params![media_item_id], + |row| row.get(0), + )?; + Ok(count > 0) +} + +/// Imports every show folder under `root` as a monitored series: matches +/// it to TVDB, populates its full episode list, then walks its actual +/// files and marks whichever episodes are already present as `has_file` +/// (instead of a fresh "add show" which starts with nothing on disk) — +/// idempotent, safe to re-run against the same root later. +pub async fn scan_tv_root( + conn: &Connection, + tvdb: &TvdbClient, + matcher: &mut TitleMatcher, + root: &Path, + quality_profile_id: i64, + jellyfin: Option<&crate::jellyfin::JellyfinClient>, +) -> Result { + let mut report = ScanReport::default(); + + for entry in subdirectories(root)? { + let folder_name = entry.file_name().to_string_lossy().to_string(); + let (title, year) = parse_folder_name(&folder_name); + + let results = match tvdb.search_series(&title).await { + Ok(r) => r, + Err(e) => { + tracing::warn!(folder = %folder_name, error = %e, "tvdb search failed"); + report.unmatched.push(folder_name); + continue; + } + }; + let Some(best) = pick_series_match(matcher, &title, results, year)? else { + report.unmatched.push(folder_name); + continue; + }; + let tvdb_id: i64 = best.external_id.parse().unwrap_or_default(); + let canonical_year = best.year.or(year); + // Normalize the folder itself down to "Title (Year)" — release + // tags/resolution/group cruft in the original folder name isn't + // wanted in the library layout, only in the source torrent name. + let series_dir = normalize_folder_name(root, &entry.path(), &title, canonical_year)?; + + let existing_id = get_media_item_by_tvdb_id(conn, tvdb_id)?; + let reuse_existing = match existing_id { + Some(id) => existing_row_title_plausibly_matches(conn, matcher, id, &title)?, + None => false, + }; + + let media_item_id = if let (Some(id), true) = (existing_id, reuse_existing) { + conn.execute( + "UPDATE media_item SET root_folder = ?1 WHERE id = ?2", + params![series_dir.to_string_lossy(), id], + )?; + id + } else { + let episodes = tvdb.episodes(&best.external_id).await?; + // Use the folder's own title, not TVDB's `name` field — for + // non-English-origin shows that's often the original-language + // title (e.g. "進撃の巨人" for Attack on Titan), while the + // folder name reflects how the user actually organizes their + // library already. + metadata::insert_series( + conn, + &best.external_id, + &title, + canonical_year, + &best.aliases, + &series_dir.to_string_lossy(), + quality_profile_id, + &episodes, + )? + }; + report.matched.push((folder_name, media_item_id)); + + for file in importer::walk_files(&series_dir)? { + let ext = file + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + if !importer::VIDEO_EXTS.contains(&ext.as_str()) { + continue; + } + let filename = file.file_name().unwrap_or_default().to_string_lossy(); + let parsed = parser::parse(&filename); + // `parsed.season` is `None` for the dominant fansub naming + // convention ("[SubsPlease] Show - 05.mkv" — absolute episode + // numbering only, no season marker at all), which previously + // skipped linking these files entirely: `has_file` never got + // set, so the daemon kept re-downloading episodes it already + // had on disk. `crate::scheduler::resolve_episode` already + // handles exactly this via the anime absolute-numbering map — + // reused here instead of duplicating it. + let Some((season, episode)) = + crate::scheduler::resolve_episode(conn, Some(tvdb_id), &parsed)? + else { + continue; + }; + let Some(episode_id) = find_episode_id(conn, media_item_id, season, episode)? else { + continue; + }; + if episode_has_file_row(conn, episode_id)? { + continue; + } + + let episode_title = get_episode_title(conn, episode_id)?; + let target_name = importer::deterministic_filename( + &title, + season, + episode, + episode_title.as_deref(), + &ext, + ); + let final_path = match rename_in_place(&file, &target_name) { + Ok(p) => { + if p != file { + report.files_renamed += 1; + } + p + } + Err(e) => { + tracing::warn!(file = %file.display(), error = %e, "rename failed, keeping original name"); + file.clone() + } + }; + + let size = std::fs::metadata(&final_path)?.len(); + conn.execute( + "INSERT INTO episode_file (episode_id, path, size_bytes, subtitle_status) VALUES (?1, ?2, ?3, 'none')", + params![episode_id, final_path.to_string_lossy(), size], + )?; + let episode_file_id = conn.last_insert_rowid(); + conn.execute( + "UPDATE episode SET has_file = 1 WHERE id = ?1", + params![episode_id], + )?; + report.files_linked += 1; + // Best-effort: a newly-linked file should get its ground-truth + // metadata right away rather than waiting for the next + // `probe_library` sweep, but a probing failure here must never + // fail the scan itself. + if let Err(e) = importer::ensure_probed(conn, episode_file_id, &final_path) { + tracing::warn!(episode_file_id, error = %e, "post-scan probing failed"); + } + } + } + + // Folder renames (via `normalize_folder_name`) change paths Jellyfin + // already has indexed under their old names — without an explicit + // refresh here, Jellyfin's own library index silently falls out of + // sync with disk until its *own* next scheduled scan happens to run, + // which could be hours away. Verified live: exactly this happened — + // renamed anime folders vanished from Jellyfin's library view because + // nothing told it to look again. + if !report.matched.is_empty() { + if let Some(jellyfin) = jellyfin { + refresh_jellyfin_after_rename(jellyfin, "tv scan").await; + } + } + + Ok(report) +} + +/// Refreshes Jellyfin's library index after a scan that may have renamed +/// folders — a single fire-and-forget attempt previously left Jellyfin's +/// index silently stale for however long it took its own next scheduled +/// scan to notice on a transient failure (Jellyfin mid-restart, a momentary +/// network blip — the realistic failure mode, not a persistent outage). +/// Retries a few times with backoff before giving up, and logs at `error` +/// (not `warn`) once retries are exhausted, since a permanently stale +/// Jellyfin index is exactly the kind of thing worth actually noticing +/// rather than scrolling past in the journal. +async fn refresh_jellyfin_after_rename(jellyfin: &crate::jellyfin::JellyfinClient, context: &str) { + const ATTEMPTS: u32 = 3; + for attempt in 1..=ATTEMPTS { + match jellyfin.refresh_library().await { + Ok(()) => return, + Err(e) if attempt < ATTEMPTS => { + tracing::warn!( + error = %e, + attempt, + context, + "jellyfin library refresh failed, retrying" + ); + tokio::time::sleep(std::time::Duration::from_secs(5 * u64::from(attempt))).await; + } + Err(e) => { + tracing::error!( + error = %e, + context, + "jellyfin library refresh failed after {ATTEMPTS} attempts — its index may \ + stay stale relative to disk until its own next scheduled scan runs" + ); + } + } + } +} + +/// One thing to match+import: either a "one folder per movie" entry (the +/// common layout) or a bare video file sitting directly under the root +/// (some libraries — e.g. this one's "Anime Movies" — are organized flat, +/// one file per movie with no per-movie folder at all). +struct MovieCandidate { + display_name: String, + /// Where to search for the actual video file: the folder itself for a + /// subdirectory entry, or the file's own path for a flat file. + video_source: std::path::PathBuf, +} + +fn movie_candidates(root: &Path) -> Result> { + let mut out: Vec = subdirectories(root)? + .into_iter() + .map(|entry| MovieCandidate { + display_name: entry.file_name().to_string_lossy().to_string(), + video_source: entry.path(), + }) + .collect(); + + for entry in std::fs::read_dir(root)? { + let entry = entry?; + let path = entry.path(); + if !path.is_file() { + continue; + } + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + if !importer::VIDEO_EXTS.contains(&ext.as_str()) { + continue; + } + let display_name = path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + out.push(MovieCandidate { + display_name, + video_source: path, + }); + } + + Ok(out) +} + +/// Ensures a movie has its own folder before it's otherwise processed — +/// restructures a bare file directly under `root` into "{Title} +/// ({Year})/{file}", matching the "one folder per movie" convention the +/// rest of the library already uses (some libraries — e.g. this one's +/// "Anime Movies" — were organized flat, one file per movie with no +/// per-movie folder at all). Same-filesystem rename, doesn't touch file +/// content; a no-op if `video_source` is already a folder. +fn ensure_movie_folder( + root: &Path, + video_source: &Path, + title: &str, + year: Option, +) -> Result { + if video_source.is_dir() { + return normalize_folder_name(root, video_source, title, year); + } + let target_dir = root.join(canonical_folder_name(title, year)); + std::fs::create_dir_all(&target_dir)?; + + let file_name = video_source + .file_name() + .context("video file has no filename")?; + let target_file = target_dir.join(file_name); + if target_file != *video_source { + std::fs::rename(video_source, &target_file)?; + } + Ok(target_dir) +} + +/// Same idea as [`scan_tv_root`] but for movies: one media_item per +/// candidate (folder or bare file, restructured into its own folder if it +/// wasn't already one), matched to TMDB, with its video file linked as its +/// `episode_file` (movies have no `episode` row — `episode_id` is NULL). +pub async fn scan_movie_root( + conn: &Connection, + tmdb: &TmdbClient, + matcher: &mut TitleMatcher, + root: &Path, + quality_profile_id: i64, + jellyfin: Option<&crate::jellyfin::JellyfinClient>, +) -> Result { + let mut report = ScanReport::default(); + + for candidate in movie_candidates(root)? { + let folder_name = candidate.display_name; + let (title, year) = parse_folder_name(&folder_name); + + let results = match tmdb.search_movie(&title).await { + Ok(r) => r, + Err(e) => { + tracing::warn!(folder = %folder_name, error = %e, "tmdb search failed"); + report.unmatched.push(folder_name); + continue; + } + }; + let Some(best) = pick_movie_match(matcher, &title, results, year)? else { + report.unmatched.push(folder_name); + continue; + }; + let tmdb_id: i64 = best.external_id.parse().unwrap_or_default(); + let canonical_year = best.year.or(year); + + // Normalizes the folder itself down to "Title (Year)" too — release + // tags/resolution/group cruft from the original folder/file name + // isn't wanted in the library layout, only in the source torrent + // name (covers both a pre-existing messily-named folder and a flat + // file getting its own folder created for the first time). + let item_root_folder = + ensure_movie_folder(root, &candidate.video_source, &title, canonical_year)?; + + let existing_id = get_media_item_by_tmdb_id(conn, tmdb_id)?; + let reuse_existing = match existing_id { + Some(id) => existing_row_title_plausibly_matches(conn, matcher, id, &title)?, + None => false, + }; + + let media_item_id = if let (Some(id), true) = (existing_id, reuse_existing) { + conn.execute( + "UPDATE media_item SET root_folder = ?1 WHERE id = ?2", + params![item_root_folder.to_string_lossy(), id], + )?; + id + } else { + conn.execute( + "INSERT INTO media_item (kind, title, year, tmdb_id, monitored, quality_profile_id, root_folder) + VALUES ('movie', ?1, ?2, ?3, 1, ?4, ?5)", + params![ + title, + canonical_year, + tmdb_id, + quality_profile_id, + item_root_folder.to_string_lossy() + ], + )?; + conn.last_insert_rowid() + }; + report.matched.push((folder_name, media_item_id)); + + if !movie_has_file_row(conn, media_item_id)? { + if let Ok(file) = importer::largest_video_file(&item_root_folder) { + let ext = file + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("mkv") + .to_lowercase(); + let target_name = importer::deterministic_movie_filename( + &title, + canonical_year.map(i64::from), + &ext, + ); + let final_path = match rename_in_place(&file, &target_name) { + Ok(p) => { + if p != file { + report.files_renamed += 1; + } + p + } + Err(e) => { + tracing::warn!(file = %file.display(), error = %e, "rename failed, keeping original name"); + file.clone() + } + }; + + let size = std::fs::metadata(&final_path)?.len(); + conn.execute( + "INSERT INTO episode_file (media_item_id, path, size_bytes, subtitle_status) VALUES (?1, ?2, ?3, 'none')", + params![media_item_id, final_path.to_string_lossy(), size], + )?; + let episode_file_id = conn.last_insert_rowid(); + report.files_linked += 1; + if let Err(e) = importer::ensure_probed(conn, episode_file_id, &final_path) { + tracing::warn!(episode_file_id, error = %e, "post-scan probing failed"); + } + } + } + } + + // See the matching comment in `scan_tv_root` — folder renames need an + // explicit refresh or Jellyfin's index silently falls out of sync with + // disk until its own next scheduled scan. + if !report.matched.is_empty() { + if let Some(jellyfin) = jellyfin { + refresh_jellyfin_after_rename(jellyfin, "movie scan").await; + } + } + + Ok(report) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_folder_name_with_year() { + assert_eq!( + parse_folder_name("Arrival (2016)"), + ("Arrival".to_string(), Some(2016)) + ); + } + + #[test] + fn parses_folder_name_without_year() { + assert_eq!( + parse_folder_name("Black Adder"), + ("Black Adder".to_string(), None) + ); + } + + #[test] + fn strips_scene_style_dot_separated_tags() { + assert_eq!( + parse_folder_name("Arrival.2016.1080p.BluRay.x264-GROUP"), + ("Arrival".to_string(), Some(2016)) + ); + } + + #[test] + fn strips_season_and_quality_tags_from_series_folder() { + assert_eq!( + parse_folder_name("Attack on Titan S01 1080p Dual Audio [Group]"), + ("Attack on Titan".to_string(), None) + ); + } + + #[test] + fn keeps_bare_numeric_title_when_no_other_year_found() { + assert_eq!( + parse_folder_name("1917.1080p.BluRay.x264-GROUP"), + ("1917".to_string(), None) + ); + } + + #[test] + fn does_not_mangle_titles_with_real_dots() { + assert_eq!( + parse_folder_name("Mr. Robot (2015)"), + ("Mr. Robot".to_string(), Some(2015)) + ); + } + + #[test] + fn strips_leading_release_group_bracket_tag_and_movie_number() { + assert_eq!( + parse_folder_name( + "[Judas] Code Geass Movie 01 - Lelouch of the Rebellion - Initiation" + ), + ( + "Code Geass Lelouch of the Rebellion - Initiation".to_string(), + None + ) + ); + } +} diff --git a/breadarrd/src/main.rs b/breadarrd/src/main.rs new file mode 100644 index 0000000..e2aa04c --- /dev/null +++ b/breadarrd/src/main.rs @@ -0,0 +1,1208 @@ +mod api; +mod db; +mod importer; +mod jellyfin; +mod library_scan; +mod matcher; +mod metadata; +mod notify; +mod parser; +mod qbit; +mod scheduler; +mod scoring; +mod sources; + +use std::env; + +use anyhow::{bail, Result}; +use breadarr_shared::Config; +use jellyfin::JellyfinClient; +use metadata::tvdb::TvdbClient; +use qbit::QbitClient; +use rusqlite::Connection; +use tracing::{error, info}; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> Result<()> { + let config = Config::load()?; + // ort logs its own session/hardware setup at INFO, which drowns out + // everything else at the default level — keep it to warnings and up. + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::new(format!( + "{},ort::logging=warn", + config.daemon.log_level + ))) + .init(); + + let args: Vec = env::args().collect(); + match args.get(1).map(String::as_str) { + Some("debug-qbit-add") => { + let Some(magnet) = args.get(2) else { + bail!("usage: breadarrd debug-qbit-add "); + }; + return debug_qbit_add(&config, magnet).await; + } + Some("debug-jellyfin-refresh") => { + return debug_jellyfin_refresh(&config).await; + } + Some("debug-tvdb-add") => { + let Some(query) = args.get(2) else { + bail!("usage: breadarrd debug-tvdb-add "); + }; + return debug_tvdb_add(&config, query).await; + } + Some("debug-anime-map-refresh") => { + return debug_anime_map_refresh(&config).await; + } + Some("debug-match-title") => { + let Some(query) = args.get(2) else { + bail!("usage: breadarrd debug-match-title "); + }; + return debug_match_title(&config, query).await; + } + Some("debug-grab-cycle") => { + let feed_url = args + .get(2) + .map(String::as_str) + .unwrap_or("https://nyaa.si/?page=rss"); + return debug_grab_cycle(&config, feed_url).await; + } + Some("debug-import-cycle") => { + return debug_import_cycle(&config).await; + } + Some("debug-1337x-search") => { + let Some(query) = args.get(2) else { + bail!("usage: breadarrd debug-1337x-search "); + }; + return debug_1337x_search(&config, query).await; + } + Some("debug-tvdb-search") => { + let Some(query) = args.get(2) else { + bail!("usage: breadarrd debug-tvdb-search "); + }; + return debug_tvdb_search(&config, query).await; + } + Some("debug-scan-tv") => { + let Some(path) = args.get(2) else { + bail!("usage: breadarrd debug-scan-tv "); + }; + return debug_scan_tv(&config, path).await; + } + Some("debug-scan-movies") => { + let Some(path) = args.get(2) else { + bail!("usage: breadarrd debug-scan-movies "); + }; + return debug_scan_movies(&config, path).await; + } + Some("debug-search-show") => { + let Some(title) = args.get(2) else { + bail!("usage: breadarrd debug-search-show "); + }; + return debug_search_show(&config, title).await; + } + Some("debug-reconcile-report") => { + return debug_reconcile_report(&config).await; + } + Some("debug-qbit-list") => { + let category = args.get(2).map(String::as_str); + return debug_qbit_list(&config, category).await; + } + Some("remux-backlog") => { + return remux_backlog_cmd(&config).await; + } + Some("probe-library") => { + return probe_library_cmd(&config).await; + } + Some("verify-library") => { + return verify_library_cmd(&config).await; + } + _ => {} + } + + run_daemon(config).await +} + +async fn run_daemon(config: Config) -> Result<()> { + info!("starting breadarrd"); + + if let Some(parent) = config.db_path().parent() { + std::fs::create_dir_all(parent)?; + } + if let Err(e) = db::backup_before_open(&config.db_path()) { + // Never block startup on a backup failure (disk full, permissions) + // — losing the safety net for this one run is far better than the + // daemon refusing to start at all. + tracing::warn!(error = %e, "database backup failed, continuing without one"); + } + let conn = Connection::open(config.db_path())?; + db::init(&conn)?; + info!(path = %config.db_path().display(), "database ready"); + + // A second, independent connection for the HTTP API rather than sharing + // `background_loop`'s. Both point at the same on-disk (WAL-mode) + // database, so this doesn't weaken consistency — it just means an API + // request (a TUI poll, a review-queue approval) is no longer serialized + // behind whatever `background_loop` happens to be doing, which used to + // include holding its connection's lock across an entire grab/import/ + // search cycle's network I/O and jitter sleeps. `busy_timeout` (set in + // `db::init`) covers the rare case where both connections genuinely + // want to write at the same instant. + let api_conn = Connection::open(config.db_path())?; + db::init(&api_conn)?; + + let listener = tokio::net::TcpListener::bind(&config.daemon.listen_addr).await?; + info!(addr = %config.daemon.listen_addr, "listening"); + + let tvdb = if config.tvdb.api_key.is_empty() { + None + } else { + Some(std::sync::Arc::new(TvdbClient::new( + config.tvdb.api_key.clone(), + ))) + }; + let qbit = if config.qbit.base_url.is_empty() { + None + } else { + let client = QbitClient::new(config.qbit.base_url.clone())?; + if !config.qbit.username.is_empty() { + client + .login(&config.qbit.username, &config.qbit.password) + .await?; + } + Some(std::sync::Arc::new(client)) + }; + let tmdb = if config.tmdb.bearer_token.is_empty() { + None + } else { + Some(std::sync::Arc::new(metadata::tmdb::TmdbClient::new( + config.tmdb.bearer_token.clone(), + ))) + }; + let (background_tx, background_rx) = tokio::sync::mpsc::channel(8); + + let background_conn = std::sync::Arc::new(tokio::sync::Mutex::new(conn)); + let state = api::AppState { + conn: std::sync::Arc::new(tokio::sync::Mutex::new(api_conn)), + tvdb, + tmdb, + qbit, + qbit_category: config.qbit.category.clone(), + cycle_status: std::sync::Arc::new(std::sync::Mutex::new(api::CycleStatus::default())), + config: config.clone(), + background_tx: None, + }; + + let background = state.qbit.clone().map(|qbit| { + let jellyfin = if config.jellyfin.base_url.is_empty() { + None + } else { + Some(JellyfinClient::new( + config.jellyfin.base_url.clone(), + config.jellyfin.api_key.clone(), + )) + }; + background_loop( + background_conn, + qbit, + jellyfin, + config.clone(), + state.cycle_status.clone(), + background_rx, + ) + }); + let mut state = state; + if background.is_some() { + // Only set once the background loop is actually going to run and + // poll the receiver end of this channel — otherwise `background_rx` + // (moved into the discarded `.map()` closure above, since `.map()` + // never calls its closure on `None`) is already dropped, and any + // send on `background_tx` would hang forever with no receiver. + state.background_tx = Some(background_tx); + } else { + tracing::warn!("qbit.base_url is not set; automatic grab/import loop is disabled"); + } + + let app = api::router(state); + // `background_loop` holds a `rusqlite::Connection` across `.await` + // points, which isn't `Send` — it has to stay a branch of this same + // root future (already not `Send`-constrained, since it's only ever + // `.await`ed directly, never `tokio::spawn`ed) rather than its own task. + tokio::select! { + result = async { axum::serve(listener, app).await } => { + if let Err(err) = result { + error!(error = %err, "http server failed"); + } + } + _ = async { + match background { + Some(fut) => fut.await, + None => std::future::pending().await, + } + } => { + // `background_loop` retries its own init and never returns in + // normal operation, so reaching here means something truly + // unexpected happened — exit non-zero rather than falling + // through to `Ok(())`, so systemd's `Restart=` actually engages + // instead of leaving a "successfully exited" daemon dead until + // someone notices by hand. + anyhow::bail!("background grab/import loop exited unexpectedly"); + } + _ = wait_for_shutdown() => { + info!("shutdown signal received"); + } + } + + Ok(()) +} + +async fn load_title_matcher(config: &Config) -> Result { + let (model_path, tokenizer_path) = matcher::ensure_model(&config.model_dir()).await?; + matcher::TitleMatcher::load(&model_path, &tokenizer_path) +} + +/// Runs the daemon's own auto-grab/import/search loop for as long as the +/// process lives. Owns its own `TitleMatcher` (the HTTP API never needs one +/// — matching only happens when a fresh release comes in here or in a +/// review-queue approval, which just replays an already-decided grab). +/// nyaa RSS (anime TV) is a feed watch; movies and non-anime TV go through +/// the search-driven loop instead (1337x, or nyaa's search mode for anime +/// movies) — see `scheduler::run_search_cycle`. +async fn background_loop( + conn: std::sync::Arc>, + qbit: std::sync::Arc, + jellyfin: Option, + config: Config, + cycle_status: std::sync::Arc>, + mut background_rx: tokio::sync::mpsc::Receiver, +) { + let notifier = notify::Notifier::new(&config.notifications.webhook_url); + { + let conn = conn.lock().await; + if let Err(e) = conn.execute( + "INSERT OR IGNORE INTO source (id, name, kind, base_url, poll_interval_secs, enabled) + VALUES (1, 'nyaa', 'rss', ?1, ?2, 1)", + rusqlite::params![ + config.sources.nyaa_rss_url, + config.sources.grab_poll_interval_secs + ], + ) { + error!(error = %e, "failed to register nyaa source row"); + } + if let Err(e) = conn.execute( + "INSERT OR IGNORE INTO source (id, name, kind, base_url, poll_interval_secs, enabled) + VALUES (2, '1337x', 'scrape', ?1, ?2, 1)", + rusqlite::params![ + config + .sources + .torrent_1337x_mirrors + .first() + .cloned() + .unwrap_or_default(), + config.sources.search_poll_interval_secs + ], + ) { + error!(error = %e, "failed to register 1337x source row"); + } + if let Err(e) = conn.execute( + "INSERT OR IGNORE INTO source (id, name, kind, base_url, poll_interval_secs, enabled) + VALUES (3, 'tpb', 'scrape', ?1, ?2, 1)", + rusqlite::params![ + config.sources.tpb_api_url, + config.sources.search_poll_interval_secs + ], + ) { + error!(error = %e, "failed to register tpb source row"); + } + } + + // A transient failure here (network blip during the one-time model + // download, momentary disk issue) must not permanently disable the + // loop — `run_daemon`'s `select!` treats a *return* from this function + // as fatal and exits the whole process, but only so systemd's restart + // policy can engage; retrying in-place first means a blip doesn't need + // a process restart at all. + let mut title_matcher = loop { + match load_title_matcher(&config).await { + Ok(m) => break m, + Err(e) => { + error!(error = %e, "failed to init title matcher; retrying in 30s"); + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + } + } + }; + + let nyaa_source = sources::rss::RssSource::new(config.sources.nyaa_rss_url.clone()); + let scrape_source = + sources::scrape::ScrapeSource::new(config.sources.torrent_1337x_mirrors.clone()); + let tpb_source = sources::tpb::TpbSource::new(config.sources.tpb_api_url.clone()); + + let mut grab_ticker = tokio::time::interval(std::time::Duration::from_secs( + config.sources.grab_poll_interval_secs, + )); + let mut import_ticker = tokio::time::interval(std::time::Duration::from_secs( + config.sources.import_poll_interval_secs, + )); + let mut search_ticker = tokio::time::interval(std::time::Duration::from_secs( + config.sources.search_poll_interval_secs, + )); + let mut upgrade_ticker = tokio::time::interval(std::time::Duration::from_secs( + config.sources.upgrade_poll_interval_secs, + )); + // Disk state doesn't change on its own — hourly is plenty to catch a + // file deleted/moved by hand without adding meaningful load (one query + // per tracked episode file, all local). Deliberately does *not* fire at + // t=0 like the other tickers (`tokio::time::interval`'s default first + // tick is immediate) — startup is exactly when a network/removable mount + // is most likely to still be coming up, and reconcile misreading an + // absent mount as a mass file deletion is the one failure mode worth + // paying a full interval's delay to avoid. + let mut reconcile_ticker = tokio::time::interval_at( + tokio::time::Instant::now() + std::time::Duration::from_secs(3600), + std::time::Duration::from_secs(3600), + ); + grab_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + import_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + search_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + upgrade_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + reconcile_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + // Cycle-level backoff on top of the search loop's own per-mirror + // cooldowns: a whole cycle failing (source exhausted, or an outright + // error) means something's more broadly wrong than one bad mirror, so + // back off the *cycle interval* itself — 1h, 2h, 4h, capped — rather + // than keep firing every `search_poll_interval_secs` regardless. + let mut search_skip_ticks: u32 = 0; + let mut search_fail_streak: u32 = 0; + + loop { + tokio::select! { + _ = grab_ticker.tick() => { + let result = { + let conn = conn.lock().await; + scheduler::run_grab_cycle(&conn, &nyaa_source, 1, &mut title_matcher, &qbit, &config.qbit.category).await + }; + if let (Ok(stats), Some(n)) = (&result, ¬ifier) { + if stats.queued_for_review > 0 { + n.send("breadarr: review queue", &format!( + "{} release(s) need manual confirmation this cycle", stats.queued_for_review + )).await; + } + } + let record = match &result { + Ok(stats) => { + info!(?stats, "grab cycle complete"); + api::CycleRecord { at: chrono::Utc::now(), ok: true, detail: format!("{stats:?}") } + } + Err(e) => { + error!(error = %e, "grab cycle failed"); + api::CycleRecord { at: chrono::Utc::now(), ok: false, detail: e.to_string() } + } + }; + cycle_status.lock().expect("cycle_status poisoned").last_grab = Some(record); + } + _ = import_ticker.tick() => { + let result = { + let conn = conn.lock().await; + importer::run_import_cycle(&conn, &qbit, jellyfin.as_ref(), &config.qbit.category, &config.qbit.container_downloads_path, &config.qbit.host_downloads_path).await + }; + if let (Ok(stats), Some(n)) = (&result, ¬ifier) { + if stats.failed > 0 { + n.send("breadarr: import failures", &format!( + "{} import(s) gave up this cycle after repeated failures", stats.failed + )).await; + } + if stats.quality_flagged > 0 { + n.send("breadarr: quality concern", &format!( + "{} freshly-imported file(s) failed a post-import ground-truth check \ + (under 1080p or unreadable, despite what the release title claimed) \ + — see the library-health report", stats.quality_flagged + )).await; + } + } + let record = match &result { + Ok(stats) => { + info!(?stats, "import cycle complete"); + api::CycleRecord { at: chrono::Utc::now(), ok: true, detail: format!("{stats:?}") } + } + Err(e) => { + error!(error = %e, "import cycle failed"); + api::CycleRecord { at: chrono::Utc::now(), ok: false, detail: e.to_string() } + } + }; + cycle_status.lock().expect("cycle_status poisoned").last_import = Some(record); + } + _ = search_ticker.tick() => { + if !config.sources.search_enabled { + continue; + } + if search_skip_ticks > 0 { + search_skip_ticks -= 1; + continue; + } + let result = { + let conn = conn.lock().await; + scheduler::run_search_cycle( + &conn, + &tpb_source, 3, + &scrape_source, 2, + &nyaa_source, 1, + &mut title_matcher, + &qbit, &config.qbit.category, + config.sources.search_budget_per_cycle, + ).await + }; + let cycle_failed = matches!(&result, Ok(stats) if stats.source_exhausted) || result.is_err(); + let record = match &result { + Ok(stats) => { + info!(?stats, "search cycle complete"); + api::CycleRecord { at: chrono::Utc::now(), ok: !stats.source_exhausted, detail: format!("{stats:?}") } + } + Err(e) => { + error!(error = %e, "search cycle failed"); + api::CycleRecord { at: chrono::Utc::now(), ok: false, detail: e.to_string() } + } + }; + if cycle_failed { + search_fail_streak = (search_fail_streak + 1).min(10); + search_skip_ticks = (1u32 << search_fail_streak.min(3)).saturating_sub(1).min(7); + } else { + search_fail_streak = 0; + search_skip_ticks = 0; + } + let just_halted = { + let mut status = cycle_status.lock().expect("cycle_status poisoned"); + status.last_search = Some(record); + let just_halted = search_fail_streak >= 10 && !status.search_halted; + if search_fail_streak >= 10 { + if just_halted { + error!("search cycle has failed 10 cycles in a row; still retrying at max backoff, but this needs attention"); + } + status.search_halted = true; + } else if !cycle_failed { + status.search_halted = false; + } + just_halted + }; + if just_halted { + if let Some(n) = ¬ifier { + n.send("breadarr: search halted", "The search-driven loop has failed 10 cycles in a row and is now at max backoff — still retrying automatically, but this needs attention.").await; + } + } + if let (Ok(stats), Some(n)) = (&result, ¬ifier) { + if stats.queued_for_review > 0 { + n.send("breadarr: review queue", &format!( + "{} release(s) need manual confirmation this cycle", stats.queued_for_review + )).await; + } + } + } + _ = upgrade_ticker.tick() => { + if !config.sources.upgrade_enabled { + continue; + } + let result = { + let conn = conn.lock().await; + scheduler::run_upgrade_cycle( + &conn, + &tpb_source, 3, + &scrape_source, 2, + &nyaa_source, 1, + &mut title_matcher, + &qbit, &config.qbit.category, + config.sources.upgrade_budget_per_cycle, + config.sources.upgrade_min_score_gain, + ).await + }; + let record = match &result { + Ok(stats) => { + info!(?stats, "upgrade cycle complete"); + api::CycleRecord { at: chrono::Utc::now(), ok: !stats.source_exhausted, detail: format!("{stats:?}") } + } + Err(e) => { + error!(error = %e, "upgrade cycle failed"); + api::CycleRecord { at: chrono::Utc::now(), ok: false, detail: e.to_string() } + } + }; + cycle_status.lock().expect("cycle_status poisoned").last_upgrade = Some(record); + if let (Ok(stats), Some(n)) = (&result, ¬ifier) { + if stats.grabbed > 0 { + n.send("breadarr: quality upgrade", &format!( + "{} file(s) replaced this cycle with a better-scoring release", stats.grabbed + )).await; + } + } + } + _ = reconcile_ticker.tick() => { + let result = { + let conn = conn.lock().await; + importer::reconcile_missing_files(&conn, false) + }; + match result { + Ok(outcome) if outcome.aborted => { + error!("missing-file reconciliation aborted: an anomalous fraction of the library looked gone at once"); + if let Some(n) = ¬ifier { + n.send("breadarr: reconcile aborted", &format!( + "reconcile found far more missing files than expected in one pass and refused to \ + touch anything — check whether a library mount is offline. Nothing was changed. \ + (details: {outcome:?})" + )).await; + } + } + Ok(outcome) if outcome.repaired > 0 || outcome.cleared > 0 => { + info!(?outcome, "reconciled library state against disk"); + if outcome.cleared > 0 { + if let Some(n) = ¬ifier { + n.send("breadarr: files went missing", &format!( + "{} file(s) tracked in the library are no longer on disk \ + (deleted or moved outside breadarr) — cleared from tracking, \ + will be re-searched if still monitored. {} other stale path(s) \ + were auto-repaired.", outcome.cleared, outcome.repaired + )).await; + } + } + } + Ok(_) => {} + Err(e) => error!(error = %e, "missing-file reconciliation failed"), + } + // Right after reconcile, in the same tick, so a path it just + // repaired (a rename or an in-place transcode) gets re-probed + // immediately rather than waiting for its own turn a full + // interval later. + match { + let conn = conn.lock().await; + importer::probe_library(&conn) + } { + Ok(report) if report.probed > 0 || report.failed > 0 => { + info!(?report, "media probe sweep complete"); + } + Ok(_) => {} + Err(e) => error!(error = %e, "media probe sweep failed"), + } + } + Some(req) = background_rx.recv() => { + match req { + api::BackgroundRequest::SearchNow { media_item_id, reply } => { + let result = { + let conn = conn.lock().await; + match scheduler::enumerate_search_targets_for_media_item(&conn, media_item_id) { + Ok(targets) => scheduler::execute_search_targets( + &conn, + &targets, + &tpb_source, 3, + &scrape_source, 2, + &nyaa_source, 1, + &mut title_matcher, + &qbit, &config.qbit.category, + ).await, + Err(e) => Err(e), + } + }; + if let Ok(stats) = &result { + info!(?stats, media_item_id, "manual search-now complete"); + } + // Ignore a send failure — the HTTP request that asked + // for this may have already timed out/disconnected, + // which doesn't invalidate the search itself (it + // still ran and any grabs it made are already + // recorded). + let _ = reply.send(result); + } + api::BackgroundRequest::FetchCandidates { media_item_id, episode_id, reply } => { + let result = { + let conn = conn.lock().await; + scheduler::fetch_candidates( + &conn, + media_item_id, + episode_id, + &tpb_source, 3, + &scrape_source, 2, + &nyaa_source, 1, + ).await + }; + let _ = reply.send(result); + } + api::BackgroundRequest::GrabCandidate { media_item_id, episode_id, source_id, raw_title, link, guid, reply } => { + let result = { + let conn = conn.lock().await; + scheduler::grab_candidate( + &conn, + &qbit, + &config.qbit.category, + media_item_id, + episode_id, + source_id, + &raw_title, + &link, + &guid, + ).await + }; + if let Ok(()) = &result { + info!(media_item_id, episode_id, raw_title, "manually picked candidate grabbed"); + } + let _ = reply.send(result); + } + } + } + } + } +} + +async fn debug_qbit_add(config: &Config, magnet: &str) -> Result<()> { + if config.qbit.base_url.is_empty() { + bail!("qbit.base_url is not set in config"); + } + let client = QbitClient::new(config.qbit.base_url.clone())?; + if !config.qbit.username.is_empty() { + client + .login(&config.qbit.username, &config.qbit.password) + .await?; + } + client.add_magnet(magnet, &config.qbit.category).await?; + println!( + "added magnet to qbittorrent (category={})", + config.qbit.category + ); + Ok(()) +} + +async fn debug_jellyfin_refresh(config: &Config) -> Result<()> { + if config.jellyfin.base_url.is_empty() { + bail!("jellyfin.base_url is not set in config"); + } + let client = JellyfinClient::new( + config.jellyfin.base_url.clone(), + config.jellyfin.api_key.clone(), + ); + client.refresh_library().await?; + println!("jellyfin library refresh triggered"); + Ok(()) +} + +async fn debug_tvdb_add(config: &Config, query: &str) -> Result<()> { + if config.tvdb.api_key.is_empty() { + bail!("tvdb.api_key is not set in config"); + } + let tvdb = TvdbClient::new(config.tvdb.api_key.clone()); + let results = tvdb.search_series(query).await?; + let Some(top) = results.into_iter().next() else { + bail!("no TVDB results for {query:?}"); + }; + println!( + "top match: {} ({:?}) tvdb_id={} aliases={}", + top.name, + top.year, + top.external_id, + top.aliases.len() + ); + + if let Some(parent) = config.db_path().parent() { + std::fs::create_dir_all(parent)?; + } + let conn = Connection::open(config.db_path())?; + db::init(&conn)?; + + let media_item_id = metadata::add_series( + &conn, + &tvdb, + &top.external_id, + &top.name, + top.year, + &top.aliases, + "/tmp/breadarr-debug-library", + 1, + ) + .await?; + + let season_count: i64 = conn.query_row( + "SELECT count(*) FROM season WHERE media_item_id = ?1", + [media_item_id], + |row| row.get(0), + )?; + let episode_count: i64 = conn.query_row( + "SELECT count(*) FROM episode WHERE media_item_id = ?1", + [media_item_id], + |row| row.get(0), + )?; + println!( + "added media_item id={media_item_id}: {season_count} seasons, {episode_count} episodes" + ); + Ok(()) +} + +async fn debug_anime_map_refresh(config: &Config) -> Result<()> { + if let Some(parent) = config.db_path().parent() { + std::fs::create_dir_all(parent)?; + } + let mut conn = Connection::open(config.db_path())?; + db::init(&conn)?; + + let client = reqwest::Client::new(); + let count = metadata::anime_map::refresh(&mut conn, &client).await?; + println!("anime_mapping refreshed: {count} entries"); + Ok(()) +} + +async fn debug_match_title(config: &Config, query: &str) -> Result<()> { + if let Some(parent) = config.db_path().parent() { + std::fs::create_dir_all(parent)?; + } + let conn = Connection::open(config.db_path())?; + db::init(&conn)?; + + let (model_path, tokenizer_path) = matcher::ensure_model(&config.model_dir()).await?; + let mut title_matcher = matcher::TitleMatcher::load(&model_path, &tokenizer_path)?; + + match title_matcher.match_title(&conn, query)? { + matcher::MatchOutcome::Auto(c) => { + println!( + "AUTO-MATCH media_item_id={} matched_text={:?} confidence={:.3}", + c.media_item_id, c.matched_text, c.confidence + ); + } + matcher::MatchOutcome::NeedsReview(c) => { + let review_id = matcher::queue_for_review(&conn, query, &c, None, None)?; + println!( + "NEEDS REVIEW (queued id={review_id}) media_item_id={} matched_text={:?} confidence={:.3}", + c.media_item_id, c.matched_text, c.confidence + ); + } + matcher::MatchOutcome::NoMatch => { + println!("NO MATCH for {query:?}"); + } + } + Ok(()) +} + +async fn debug_grab_cycle(config: &Config, feed_url: &str) -> Result<()> { + if config.qbit.base_url.is_empty() { + bail!("qbit.base_url is not set in config"); + } + if let Some(parent) = config.db_path().parent() { + std::fs::create_dir_all(parent)?; + } + let conn = Connection::open(config.db_path())?; + db::init(&conn)?; + + conn.execute( + "INSERT OR IGNORE INTO source (id, name, kind, base_url, poll_interval_secs, enabled) + VALUES (1, 'nyaa', 'rss', ?1, 300, 1)", + [feed_url], + )?; + + let qbit = QbitClient::new(config.qbit.base_url.clone())?; + if !config.qbit.username.is_empty() { + qbit.login(&config.qbit.username, &config.qbit.password) + .await?; + } + + let (model_path, tokenizer_path) = matcher::ensure_model(&config.model_dir()).await?; + let mut title_matcher = matcher::TitleMatcher::load(&model_path, &tokenizer_path)?; + + let source = sources::rss::RssSource::new(feed_url); + let stats = scheduler::run_grab_cycle( + &conn, + &source, + 1, + &mut title_matcher, + &qbit, + &config.qbit.category, + ) + .await?; + + println!("{stats:?}"); + Ok(()) +} + +async fn debug_import_cycle(config: &Config) -> Result<()> { + if config.qbit.base_url.is_empty() { + bail!("qbit.base_url is not set in config"); + } + if let Some(parent) = config.db_path().parent() { + std::fs::create_dir_all(parent)?; + } + let conn = Connection::open(config.db_path())?; + db::init(&conn)?; + + let qbit = QbitClient::new(config.qbit.base_url.clone())?; + if !config.qbit.username.is_empty() { + qbit.login(&config.qbit.username, &config.qbit.password) + .await?; + } + + let jellyfin = if config.jellyfin.base_url.is_empty() { + None + } else { + Some(JellyfinClient::new( + config.jellyfin.base_url.clone(), + config.jellyfin.api_key.clone(), + )) + }; + + let stats = importer::run_import_cycle( + &conn, + &qbit, + jellyfin.as_ref(), + &config.qbit.category, + &config.qbit.container_downloads_path, + &config.qbit.host_downloads_path, + ) + .await?; + println!("{stats:?}"); + Ok(()) +} + +async fn debug_1337x_search(config: &Config, query: &str) -> Result<()> { + let source = sources::scrape::ScrapeSource::new(config.sources.torrent_1337x_mirrors.clone()); + let items = sources::ReleaseSource::fetch(&source, Some(query)).await?; + println!("{} results", items.len()); + let mut sorted = items; + sorted.sort_by_key(|i| std::cmp::Reverse(i.seeders.unwrap_or(0))); + for item in sorted.iter().take(10) { + println!( + " seeders={:<6} leechers={:<6} size={:>10} {}", + item.seeders.unwrap_or(0), + item.leechers.unwrap_or(0), + item.size_bytes + .map(|b| format!("{:.1}MB", b as f64 / 1_048_576.0)) + .unwrap_or_default(), + item.title + ); + } + + if let Some(top) = sorted.first() { + println!("\nresolving magnet for top result: {}", top.title); + let client = reqwest::Client::new(); + let magnet = sources::scrape::resolve_magnet(&client, &top.link).await?; + println!("magnet: {}", &magnet[..magnet.len().min(120)]); + } + Ok(()) +} + +async fn debug_tvdb_search(config: &Config, query: &str) -> Result<()> { + if config.tvdb.api_key.is_empty() { + bail!("tvdb.api_key is not set in config"); + } + let tvdb = TvdbClient::new(config.tvdb.api_key.clone()); + let results = tvdb.search_series(query).await?; + println!("{} results for {query:?}", results.len()); + for r in &results { + println!( + " id={} name={:?} year={:?} aliases={:?}", + r.external_id, r.name, r.year, r.aliases + ); + } + Ok(()) +} + +async fn debug_scan_tv(config: &Config, path: &str) -> Result<()> { + if config.tvdb.api_key.is_empty() { + bail!("tvdb.api_key is not set in config"); + } + if let Some(parent) = config.db_path().parent() { + std::fs::create_dir_all(parent)?; + } + let conn = Connection::open(config.db_path())?; + db::init(&conn)?; + + let tvdb = TvdbClient::new(config.tvdb.api_key.clone()); + let (model_path, tokenizer_path) = matcher::ensure_model(&config.model_dir()).await?; + let mut title_matcher = matcher::TitleMatcher::load(&model_path, &tokenizer_path)?; + let jellyfin = if config.jellyfin.base_url.is_empty() { + None + } else { + Some(JellyfinClient::new( + config.jellyfin.base_url.clone(), + config.jellyfin.api_key.clone(), + )) + }; + let report = library_scan::scan_tv_root( + &conn, + &tvdb, + &mut title_matcher, + std::path::Path::new(path), + 1, + jellyfin.as_ref(), + ) + .await?; + + println!( + "matched={} unmatched={} files_linked={} files_renamed={}", + report.matched.len(), + report.unmatched.len(), + report.files_linked, + report.files_renamed + ); + if !report.unmatched.is_empty() { + println!("unmatched:"); + for name in &report.unmatched { + println!(" {name}"); + } + } + Ok(()) +} + +async fn debug_scan_movies(config: &Config, path: &str) -> Result<()> { + if config.tmdb.bearer_token.is_empty() { + bail!("tmdb.bearer_token is not set in config"); + } + if let Some(parent) = config.db_path().parent() { + std::fs::create_dir_all(parent)?; + } + let conn = Connection::open(config.db_path())?; + db::init(&conn)?; + + let tmdb = metadata::tmdb::TmdbClient::new(config.tmdb.bearer_token.clone()); + let (model_path, tokenizer_path) = matcher::ensure_model(&config.model_dir()).await?; + let mut title_matcher = matcher::TitleMatcher::load(&model_path, &tokenizer_path)?; + let jellyfin = if config.jellyfin.base_url.is_empty() { + None + } else { + Some(JellyfinClient::new( + config.jellyfin.base_url.clone(), + config.jellyfin.api_key.clone(), + )) + }; + let report = library_scan::scan_movie_root( + &conn, + &tmdb, + &mut title_matcher, + std::path::Path::new(path), + 2, + jellyfin.as_ref(), + ) + .await?; + + println!( + "matched={} unmatched={} files_linked={} files_renamed={}", + report.matched.len(), + report.unmatched.len(), + report.files_linked, + report.files_renamed + ); + if !report.unmatched.is_empty() { + println!("unmatched:"); + for name in &report.unmatched { + println!(" {name}"); + } + } + Ok(()) +} + +/// Manually scoped acquisition pass over a single already-tracked show or +/// movie: every currently-missing episode, in one bounded run — not subject +/// to the background loop's per-cycle budget or due-ness cadence (those +/// exist to pace *unattended, indefinite* operation; a one-off, explicitly +/// requested pass over one title's own backlog doesn't need throttling +/// against itself). Still uses the same mirror rotation/cooldown and +/// per-item jitter as the background loop, so it stays no more aggressive +/// per request than normal operation — just not spread across hours. +async fn debug_search_show(config: &Config, title: &str) -> Result<()> { + if config.qbit.base_url.is_empty() { + bail!("qbit.base_url is not set in config"); + } + if let Some(parent) = config.db_path().parent() { + std::fs::create_dir_all(parent)?; + } + let conn = Connection::open(config.db_path())?; + db::init(&conn)?; + + let Some(media_item_id) = scheduler::find_media_item_id_by_title(&conn, title)? else { + bail!("no tracked media_item with title {title:?} (must match exactly)"); + }; + + conn.execute( + "INSERT OR IGNORE INTO source (id, name, kind, base_url, poll_interval_secs, enabled) + VALUES (1, 'nyaa', 'rss', ?1, ?2, 1)", + rusqlite::params![ + config.sources.nyaa_rss_url, + config.sources.grab_poll_interval_secs + ], + )?; + conn.execute( + "INSERT OR IGNORE INTO source (id, name, kind, base_url, poll_interval_secs, enabled) + VALUES (2, '1337x', 'scrape', ?1, ?2, 1)", + rusqlite::params![ + config + .sources + .torrent_1337x_mirrors + .first() + .cloned() + .unwrap_or_default(), + config.sources.search_poll_interval_secs + ], + )?; + conn.execute( + "INSERT OR IGNORE INTO source (id, name, kind, base_url, poll_interval_secs, enabled) + VALUES (3, 'tpb', 'scrape', ?1, ?2, 1)", + rusqlite::params![ + config.sources.tpb_api_url, + config.sources.search_poll_interval_secs + ], + )?; + + let qbit = QbitClient::new(config.qbit.base_url.clone())?; + if !config.qbit.username.is_empty() { + qbit.login(&config.qbit.username, &config.qbit.password) + .await?; + } + let mut title_matcher = load_title_matcher(config).await?; + let tpb_source = sources::tpb::TpbSource::new(config.sources.tpb_api_url.clone()); + let scrape_source = + sources::scrape::ScrapeSource::new(config.sources.torrent_1337x_mirrors.clone()); + let nyaa_source = sources::rss::RssSource::new(config.sources.nyaa_rss_url.clone()); + + let targets = scheduler::enumerate_search_targets_for_media_item(&conn, media_item_id)?; + println!("{} missing episode(s)/movie for {title:?}", targets.len()); + + let stats = scheduler::execute_search_targets( + &conn, + &targets, + &tpb_source, + 3, + &scrape_source, + 2, + &nyaa_source, + 1, + &mut title_matcher, + &qbit, + &config.qbit.category, + ) + .await?; + println!("{stats:?}"); + Ok(()) +} + +/// Dry-run report of what `reconcile_missing_files` would do against the +/// configured database, without writing anything — meant to be run by hand +/// against a freshly-restored or otherwise suspect database before trusting +/// the daemon's own hourly reconcile ticker to run unattended against it. +async fn debug_reconcile_report(config: &Config) -> Result<()> { + if let Some(parent) = config.db_path().parent() { + std::fs::create_dir_all(parent)?; + } + let conn = Connection::open(config.db_path())?; + db::init(&conn)?; + + let outcome = importer::reconcile_missing_files(&conn, true)?; + println!("{outcome:?}"); + if outcome.aborted { + println!( + "ABORTED: too many files looked missing at once (see logs) — do not run live \ + reconcile against this database until you've investigated why." + ); + } else if outcome.cleared > 20 { + println!( + "NOTE: {} file(s) would be cleared as genuinely missing. Double-check this is \ + expected before starting the daemon normally.", + outcome.cleared + ); + } + Ok(()) +} + +/// Lists qBittorrent torrents (optionally filtered by category) as +/// tab-separated hash/progress/state/name — read-only, and deliberately +/// prints nothing about how the client authenticated (credentials never +/// leave `QbitClient`). Meant for ad-hoc reconciliation between a `release` +/// row's `raw_title` and qBittorrent's own view when a hash needs to be +/// looked up or double-checked by hand. +async fn debug_qbit_list(config: &Config, category: Option<&str>) -> Result<()> { + if config.qbit.base_url.is_empty() { + bail!("qbit.base_url is not set in config"); + } + let qbit = QbitClient::new(config.qbit.base_url.clone())?; + if !config.qbit.username.is_empty() { + qbit.login(&config.qbit.username, &config.qbit.password) + .await?; + } + let torrents = qbit.list_torrents(category).await?; + for t in &torrents { + println!("{}\t{:.4}\t{}\t{}", t.hash, t.progress, t.state, t.name); + } + eprintln!("{} torrent(s)", torrents.len()); + Ok(()) +} + +/// Sweeps the whole library for files flagged `flag_non_english_default_audio` +/// (a non-English track set as default) and applies the same track-promotion +/// fix already used automatically right after a fresh download — but against +/// files already sitting in the library. Deliberately a manual command, not +/// wired to any ticker: unlike the read-mostly `probe_library` sweep, this +/// rewrites real files. +async fn remux_backlog_cmd(config: &Config) -> Result<()> { + if let Some(parent) = config.db_path().parent() { + std::fs::create_dir_all(parent)?; + } + let conn = Connection::open(config.db_path())?; + db::init(&conn)?; + + let report = importer::remux_backlog(&conn)?; + println!("{report:?}"); + Ok(()) +} + +/// Runs `probe_library` repeatedly until a full pass finds nothing left to +/// probe — the running daemon does this incrementally (bounded per hourly +/// tick, see `PROBE_SWEEP_BATCH_LIMIT`) so a large existing library backlog +/// doesn't stall the grab/import/search cycles; this command is for +/// immediately backfilling that same backlog by hand instead of waiting for +/// it to trickle in over several hours. +async fn probe_library_cmd(config: &Config) -> Result<()> { + if let Some(parent) = config.db_path().parent() { + std::fs::create_dir_all(parent)?; + } + let conn = Connection::open(config.db_path())?; + db::init(&conn)?; + + let mut total_probed = 0usize; + let mut total_failed = 0usize; + loop { + let report = importer::probe_library(&conn)?; + total_probed += report.probed; + total_failed += report.failed; + println!("batch: {report:?}"); + if report.probed == 0 { + break; + } + } + println!("done: {total_probed} probed total, {total_failed} failed total"); + Ok(()) +} + +/// Runs `importer::verify_library` — the expensive full-decode corruption +/// check (`ffmpeg -xerror`, actually decoding every frame) against every +/// header-probed-ok file that hasn't been decode-verified yet. Unlike +/// `probe_library_cmd`, this deliberately doesn't loop to a fixed point: +/// each file can take minutes, so one full pass over whatever's currently +/// unverified is the whole point of a single invocation — run it again +/// later (or on a cron) to pick up files added since. +async fn verify_library_cmd(config: &Config) -> Result<()> { + if let Some(parent) = config.db_path().parent() { + std::fs::create_dir_all(parent)?; + } + let conn = Connection::open(config.db_path())?; + db::init(&conn)?; + + let report = importer::verify_library(&conn)?; + println!("{report:?}"); + Ok(()) +} + +async fn wait_for_shutdown() { + let ctrl_c = tokio::signal::ctrl_c(); + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + let mut sigterm = + signal(SignalKind::terminate()).expect("failed to install SIGTERM handler"); + tokio::select! { + _ = ctrl_c => {}, + _ = sigterm.recv() => {}, + } + } + #[cfg(not(unix))] + { + let _ = ctrl_c.await; + } +} diff --git a/breadarrd/src/matcher/embed.rs b/breadarrd/src/matcher/embed.rs new file mode 100644 index 0000000..4f9bdf9 --- /dev/null +++ b/breadarrd/src/matcher/embed.rs @@ -0,0 +1,160 @@ +use std::path::Path; + +use anyhow::Result; +use ort::session::builder::GraphOptimizationLevel; +use ort::session::Session; +use ort::value::Tensor; +use tokenizers::Tokenizer; + +/// all-MiniLM-L6-v2's trained max sequence length. Release/show titles are +/// always far shorter than this, but truncate defensively rather than let a +/// pathological input blow up attention memory. +const MAX_SEQ_LEN: usize = 256; + +pub struct OrtEmbedder { + session: Session, + tokenizer: Tokenizer, + dim: usize, +} + +impl OrtEmbedder { + /// CPU-only — no execution-provider selection. A GPU on a typical media + /// server is usually already busy with transcoding, and a 90MB + /// MiniLM-class model is cheap enough on CPU that a multi-backend GPU + /// setup isn't worth the added complexity for a model this small. + pub fn load(model_path: &Path, tokenizer_path: &Path, dim: usize) -> Result { + let session = Session::builder() + .map_err(|e| anyhow::anyhow!("failed to create ort session builder: {e}"))? + .with_optimization_level(GraphOptimizationLevel::Level3) + .map_err(|e| anyhow::anyhow!("failed to set optimization level: {e}"))? + .commit_from_file(model_path) + .map_err(|e| { + anyhow::anyhow!("failed to load model from {}: {e}", model_path.display()) + })?; + + let tokenizer = Tokenizer::from_file(tokenizer_path) + .map_err(|e| anyhow::anyhow!("failed to load tokenizer: {e}"))?; + + Ok(Self { + session, + tokenizer, + dim, + }) + } + + pub fn embed(&mut self, text: &str) -> Result> { + let encoding = self + .tokenizer + .encode(text, true) + .map_err(|e| anyhow::anyhow!("tokenization failed: {e}"))?; + + let mut ids: Vec = encoding.get_ids().iter().map(|&x| x as i64).collect(); + let mut mask: Vec = encoding + .get_attention_mask() + .iter() + .map(|&x| x as i64) + .collect(); + let mut type_ids: Vec = encoding.get_type_ids().iter().map(|&x| x as i64).collect(); + + ids.truncate(MAX_SEQ_LEN); + mask.truncate(MAX_SEQ_LEN); + type_ids.truncate(MAX_SEQ_LEN); + + let seq_len = ids.len() as i64; + let id_tensor = Tensor::::from_array((vec![1i64, seq_len], ids)) + .map_err(|e| anyhow::anyhow!("failed to build input_ids tensor: {e}"))?; + let mask_tensor = Tensor::::from_array((vec![1i64, seq_len], mask.clone())) + .map_err(|e| anyhow::anyhow!("failed to build attention_mask tensor: {e}"))?; + let type_tensor = Tensor::::from_array((vec![1i64, seq_len], type_ids)) + .map_err(|e| anyhow::anyhow!("failed to build token_type_ids tensor: {e}"))?; + + let outputs = self + .session + .run(ort::inputs! { + "input_ids" => id_tensor, + "attention_mask" => mask_tensor, + "token_type_ids" => type_tensor, + }) + .map_err(|e| anyhow::anyhow!("ort inference failed: {e}"))?; + + let (shape, data) = outputs["last_hidden_state"] + .try_extract_tensor::() + .map_err(|e| anyhow::anyhow!("failed to extract last_hidden_state: {e}"))?; + + let actual_seq = shape[1] as usize; + let actual_dim = shape[2] as usize; + + // Mean-pool over non-padded positions only. + let mut result = vec![0.0f32; actual_dim]; + let mut count = 0usize; + for t in 0..actual_seq.min(mask.len()) { + if mask[t] > 0 { + for d in 0..actual_dim { + result[d] += data[t * actual_dim + d]; + } + count += 1; + } + } + if count > 0 { + for x in &mut result { + *x /= count as f32; + } + } + + l2_normalize(&mut result); + result.truncate(self.dim); + while result.len() < self.dim { + result.push(0.0); + } + + Ok(result) + } +} + +fn l2_normalize(v: &mut [f32]) { + let norm: f32 = v.iter().map(|x| x * x).sum::().sqrt(); + if norm > 1e-10 { + for x in v.iter_mut() { + *x /= norm; + } + } +} + +pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { + a.iter().zip(b).map(|(x, y)| x * y).sum() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn l2_normalize_produces_unit_vector() { + let mut v = vec![3.0, 4.0]; + l2_normalize(&mut v); + let norm: f32 = v.iter().map(|x| x * x).sum::().sqrt(); + assert!((norm - 1.0).abs() < 1e-6); + } + + #[test] + fn l2_normalize_leaves_zero_vector_untouched() { + let mut v = vec![0.0, 0.0, 0.0]; + l2_normalize(&mut v); + assert_eq!(v, vec![0.0, 0.0, 0.0]); + } + + #[test] + fn cosine_similarity_of_identical_unit_vectors_is_one() { + let mut v = vec![1.0, 2.0, 3.0]; + l2_normalize(&mut v); + let sim = cosine_similarity(&v, &v); + assert!((sim - 1.0).abs() < 1e-6); + } + + #[test] + fn cosine_similarity_of_orthogonal_vectors_is_zero() { + let a = vec![1.0, 0.0]; + let b = vec![0.0, 1.0]; + assert!(cosine_similarity(&a, &b).abs() < 1e-6); + } +} diff --git a/breadarrd/src/matcher/mod.rs b/breadarrd/src/matcher/mod.rs new file mode 100644 index 0000000..ef19a56 --- /dev/null +++ b/breadarrd/src/matcher/mod.rs @@ -0,0 +1,278 @@ +pub mod embed; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use rusqlite::{params, Connection}; + +use embed::{cosine_similarity, OrtEmbedder}; + +const MODEL_URL: &str = + "https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx"; +const TOKENIZER_URL: &str = + "https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/main/tokenizer.json"; + +/// Downloads the embedding model into `model_dir` if it isn't already +/// there — keeps setup to "run the daemon," no separate fetch step, in +/// keeping with the project's minimal-setup goal. +pub async fn ensure_model(model_dir: &Path) -> Result<(PathBuf, PathBuf)> { + std::fs::create_dir_all(model_dir) + .with_context(|| format!("failed to create {}", model_dir.display()))?; + + let model_path = model_dir.join("model.onnx"); + let tokenizer_path = model_dir.join("tokenizer.json"); + + if !model_path.exists() { + tracing::info!("downloading title-matching model (~90MB, one-time)"); + download(MODEL_URL, &model_path).await?; + } + if !tokenizer_path.exists() { + download(TOKENIZER_URL, &tokenizer_path).await?; + } + + Ok((model_path, tokenizer_path)) +} + +async fn download(url: &str, dest: &Path) -> Result<()> { + let bytes = reqwest::get(url) + .await + .with_context(|| format!("failed to download {url}"))? + .error_for_status() + .with_context(|| format!("{url} returned an error status"))? + .bytes() + .await + .with_context(|| format!("failed to read response body from {url}"))?; + let tmp = dest.with_extension("part"); + std::fs::write(&tmp, &bytes).with_context(|| format!("failed to write {}", tmp.display()))?; + std::fs::rename(&tmp, dest) + .with_context(|| format!("failed to finalize {}", dest.display()))?; + Ok(()) +} + +const EMBEDDING_DIM: usize = 384; + +/// Below this cosine similarity, no match is proposed at all. +pub(crate) const MIN_CONFIDENCE: f32 = 0.55; +/// At or above this, auto-match without queuing for manual review — but see +/// `token_overlap_ratio` below, which gates this further. +const AUTO_MATCH_CONFIDENCE: f32 = 0.85; +/// Auto-match additionally requires at least this much literal word overlap +/// between the query and the matched title/alias. all-MiniLM-L6-v2 is +/// trained on English semantic similarity; on romanized Japanese anime +/// titles it doesn't actually discriminate between shows — it clusters +/// *any* two romaji titles close together (shared particles/phonetics read +/// as "similar foreign text"), producing confidently wrong matches between +/// completely unrelated series (verified live: multiple unrelated +/// currently-airing anime auto-matched to "Demon Slayer" and "Mushoku +/// Tensei" at >0.85 confidence with zero actual relation). Requiring some +/// real word overlap catches exactly this failure mode — a genuine match +/// (including a registered alias) shares at least one real token; a +/// same-cluster false positive usually shares none. +const MIN_TOKEN_OVERLAP: f32 = 0.2; + +#[derive(Debug, Clone, PartialEq)] +pub struct MatchCandidate { + pub media_item_id: i64, + pub matched_text: String, + pub confidence: f32, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum MatchOutcome { + Auto(MatchCandidate), + NeedsReview(MatchCandidate), + NoMatch, +} + +pub struct TitleMatcher { + embedder: OrtEmbedder, + cache: HashMap>, +} + +impl TitleMatcher { + pub fn load(model_path: &Path, tokenizer_path: &Path) -> Result { + Ok(Self { + embedder: OrtEmbedder::load(model_path, tokenizer_path, EMBEDDING_DIM)?, + cache: HashMap::new(), + }) + } + + fn embed_cached(&mut self, text: &str) -> Result> { + if let Some(v) = self.cache.get(text) { + return Ok(v.clone()); + } + let v = self.embedder.embed(text)?; + self.cache.insert(text.to_string(), v.clone()); + Ok(v) + } + + /// Given a flat list of candidate texts (e.g. every search result's + /// name plus its aliases, flattened with an index back to which result + /// each one belongs to), returns the index of whichever candidate text + /// best matches `query` and its similarity score. Generic version of + /// the logic `match_title` uses against the DB — also used to + /// disambiguate metadata-provider search results, where the top result + /// isn't always the right entry (TVDB's own relevance ranking can + /// place a spinoff/short above the main series, e.g. searching "Attack + /// on Titan" ranks "Attack on Titan: Counter Rockets" first even + /// though the real series' own alias list has "Attack on Titan + /// (2013)", which should score far closer to the query). + pub fn best_match_index( + &mut self, + query: &str, + candidates: &[(usize, String)], + ) -> Result> { + let query_emb = self.embed_cached(query)?; + let mut best: Option<(usize, f32)> = None; + for (owner_index, text) in candidates { + let emb = self.embed_cached(text)?; + let score = cosine_similarity(&query_emb, &emb); + if best.as_ref().is_none_or(|(_, s)| score > *s) { + best = Some((*owner_index, score)); + } + } + Ok(best) + } + + /// Matches `query` against every monitored media_item's title + known + /// aliases, returning the single best match and whether it clears the + /// auto-match bar or needs a human to confirm it in the review queue. + pub fn match_title(&mut self, conn: &Connection, query: &str) -> Result { + let query_emb = self.embed_cached(query)?; + + let mut stmt = conn.prepare( + "SELECT id, title FROM media_item WHERE monitored = 1 + UNION ALL + SELECT a.media_item_id, a.text FROM alias a + JOIN media_item m ON m.id = a.media_item_id WHERE m.monitored = 1", + )?; + let candidates: Vec<(i64, String)> = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::>()?; + + let mut best: Option = None; + for (media_item_id, text) in candidates { + let emb = self.embed_cached(&text)?; + let confidence = cosine_similarity(&query_emb, &emb); + if confidence < MIN_CONFIDENCE { + continue; + } + if best.as_ref().is_none_or(|b| confidence > b.confidence) { + best = Some(MatchCandidate { + media_item_id, + matched_text: text, + confidence, + }); + } + } + + Ok(match best { + None => MatchOutcome::NoMatch, + // The embedding-clustering failure mode this guards against + // (see this struct's field docs / `MIN_TOKEN_OVERLAP`) lands + // its false positives anywhere in [MIN_CONFIDENCE, + // AUTO_MATCH_CONFIDENCE) just as often as above it — a + // completely unrelated romaji title scores confidently + // *enough* to pass MIN_CONFIDENCE, just not confidently enough + // to auto-match. Gating only the auto-match branch left every + // one of those false positives landing in the review queue + // instead, silently flooding it (verified live: dozens of + // zero-overlap titles queued against a handful of "attractor" + // shows). Zero real word overlap means "not a candidate at + // all," not "uncertain, ask a human." + Some(c) if token_overlap_ratio(query, &c.matched_text) < MIN_TOKEN_OVERLAP => { + MatchOutcome::NoMatch + } + Some(c) if c.confidence >= AUTO_MATCH_CONFIDENCE => MatchOutcome::Auto(c), + Some(c) => MatchOutcome::NeedsReview(c), + }) + } +} + +/// Word-level overlap between `a` and `b`, as a fraction of the *shorter* +/// title's word count — so a short alias fully contained in a longer title +/// (or vice versa) still scores 1.0, rather than being penalized by length +/// mismatch. Case-insensitive, splits on non-alphanumeric runs. +fn token_overlap_ratio(a: &str, b: &str) -> f32 { + let tokenize = |s: &str| -> std::collections::HashSet { + s.split(|c: char| !c.is_alphanumeric()) + .filter(|t| !t.is_empty()) + .map(|t| t.to_lowercase()) + .collect() + }; + let ta = tokenize(a); + let tb = tokenize(b); + let shorter = ta.len().min(tb.len()); + if shorter == 0 { + return 0.0; + } + let overlap = ta.intersection(&tb).count(); + overlap as f32 / shorter as f32 +} + +/// `link`/`source_id` are stored (not just the title) so an approval can +/// later actually complete the grab, rather than just recording a decision. +/// Both `None` when there's no real source item behind the query (e.g. an +/// ad hoc matcher test) — such an entry can be reviewed but not approved. +pub fn queue_for_review( + conn: &Connection, + raw_release_title: &str, + candidate: &MatchCandidate, + link: Option<&str>, + source_id: Option, +) -> Result { + conn.execute( + "INSERT INTO review_queue (raw_release_title, candidate_media_item_id, confidence, link, source_id, status, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, 'pending', datetime('now'))", + params![raw_release_title, candidate.media_item_id, candidate.confidence, link, source_id], + )?; + crate::db::record_event( + conn, + candidate.media_item_id, + None, + "review_queued", + &format!( + "confidence={:.2} title={raw_release_title:?}", + candidate.confidence + ), + )?; + Ok(conn.last_insert_rowid()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identical_titles_fully_overlap() { + assert_eq!(token_overlap_ratio("Mushoku Tensei", "Mushoku Tensei"), 1.0); + } + + #[test] + fn short_alias_contained_in_longer_title_fully_overlaps() { + // The alias is fully covered by the longer title's words, so this + // should score 1.0 despite the length difference — an alias like + // "Mushoku Tensei" shouldn't be penalized against the full title + // "Mushoku Tensei: Jobless Reincarnation". + let ratio = token_overlap_ratio("Mushoku Tensei", "Mushoku Tensei Jobless Reincarnation"); + assert!((ratio - 1.0).abs() < 1e-6, "ratio was {ratio}"); + } + + #[test] + fn unrelated_titles_have_no_overlap() { + // The real failure case this guards against: two unrelated + // romanized Japanese anime titles that an English-trained embedding + // model scores as deceptively similar. + let ratio = token_overlap_ratio( + "Sekai Saikyou no Kouei", + "Kimi wo Aisuru Ki wa Nai to Itta Jiki Koushaku-sama ga Nazeka Dekiai Shitekimasu", + ); + assert_eq!(ratio, 0.0); + } + + #[test] + fn empty_input_has_zero_overlap() { + assert_eq!(token_overlap_ratio("", "Mushoku Tensei"), 0.0); + } +} diff --git a/breadarrd/src/metadata/anime_map.rs b/breadarrd/src/metadata/anime_map.rs new file mode 100644 index 0000000..766b571 --- /dev/null +++ b/breadarrd/src/metadata/anime_map.rs @@ -0,0 +1,200 @@ +use anyhow::{Context, Result}; +use rusqlite::{params, Connection}; +use serde::Deserialize; + +/// Fribb/anime-lists cross-references AniDB anime entries (roughly one per +/// TV season/cour) to TVDB/TMDB ids and season numbers — this is what +/// Sonarr itself relies on for anime scene-numbering, and unlike +/// manami-project/anime-offline-database it actually carries TVDB/TMDB ids +/// (verified: the offline-database has none at all). +const SOURCE_URL: &str = + "https://raw.githubusercontent.com/Fribb/anime-lists/master/anime-list-full.json"; + +#[derive(Debug, Deserialize)] +struct Entry { + #[serde(default)] + anidb_id: Option, + #[serde(default)] + tvdb_id: Option, + #[serde(default)] + themoviedb_id: Option, + #[serde(default)] + season: Option, + #[serde(default)] + episode_offset: Option, +} + +#[derive(Debug, Deserialize)] +struct SeasonField { + tvdb: Option, +} + +#[derive(Debug, Deserialize)] +struct OffsetField { + tvdb: Option, +} + +/// Downloads the current Fribb/anime-lists dataset and upserts it into +/// `anime_mapping` (TV) and `anime_tmdb_movie` (movies). Returns the number +/// of `anime_mapping` entries processed (movie ids aren't counted the same +/// way — one entry can contribute several). +pub async fn refresh(conn: &mut Connection, client: &reqwest::Client) -> Result { + let bytes = client + .get(SOURCE_URL) + .send() + .await + .context("anime-lists download failed")? + .error_for_status() + .context("anime-lists download returned an error status")? + .bytes() + .await + .context("anime-lists download body read failed")?; + + let entries: Vec = + serde_json::from_slice(&bytes).context("anime-lists response was not valid JSON")?; + + let tx = conn.transaction()?; + let mut written = 0; + for e in &entries { + // `themoviedb_id` carries either a `tv` id (a single number) or a + // `movie` id list (rereleases/split cuts can give one AniDB entry + // several TMDB movie ids) — never both in practice, but nothing + // guarantees that, so both are checked independently rather than + // assuming one implies the absence of the other. + let tv_tmdb_id = e + .themoviedb_id + .as_ref() + .and_then(|v| v.get("tv")) + .and_then(|v| v.as_i64()); + for movie_id in e + .themoviedb_id + .as_ref() + .and_then(|v| v.get("movie")) + .and_then(|v| v.as_array()) + .into_iter() + .flatten() + .filter_map(|v| v.as_i64()) + { + tx.execute( + "INSERT OR IGNORE INTO anime_tmdb_movie (tmdb_id) VALUES (?1)", + params![movie_id], + )?; + } + + // ~63% of entries have no AniDB cross-reference at all (AniList/MAL/Kitsu-only + // listings) — irrelevant to a table keyed on anidb_id, skip them. + let Some(anidb_id) = e.anidb_id else { + continue; + }; + + let season_number = e.season.as_ref().and_then(|s| s.tvdb); + let episode_offset = e.episode_offset.as_ref().and_then(|o| o.tvdb).unwrap_or(0); + + tx.execute( + "INSERT INTO anime_mapping (anidb_id, tvdb_id, tmdb_id, season_offset, episode_offset) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(anidb_id) DO UPDATE SET + tvdb_id = excluded.tvdb_id, + tmdb_id = excluded.tmdb_id, + season_offset = excluded.season_offset, + episode_offset = excluded.episode_offset", + params![ + anidb_id, + e.tvdb_id, + tv_tmdb_id, + season_number, + episode_offset + ], + )?; + written += 1; + } + tx.commit()?; + + Ok(written) +} + +/// Resolves an absolute anime episode number to a (season, episode) pair +/// for a known TVDB series. +/// +/// One TVDB season is often stitched together from several AniDB cours, +/// each its own `anime_mapping` row with its own `episode_offset` — e.g. +/// tvdb_id 366263 (Ascendance of a Bookworm) has cours starting at absolute +/// episodes 1, 15, 27, 37 (offsets 0, 14, 26, 36). The matching cour is the +/// one with the largest offset that's still below the target absolute +/// episode (offset = episode count preceding that cour, so +/// `local_episode = absolute - offset`). +pub fn resolve_absolute_episode( + conn: &Connection, + tvdb_id: i64, + absolute_episode: u32, +) -> Result> { + let mut stmt = conn.prepare( + "SELECT season_offset, episode_offset FROM anime_mapping + WHERE tvdb_id = ?1 AND season_offset IS NOT NULL", + )?; + let rows = stmt + .query_map(params![tvdb_id], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)) + })? + .collect::>>()?; + + let absolute = absolute_episode as i64; + let best = rows + .into_iter() + .filter(|&(_, offset)| offset < absolute) + .max_by_key(|&(_, offset)| offset); + + Ok(best.map(|(season, offset)| (season as u32, (absolute - offset) as u32))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn seeded_conn() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE anime_mapping ( + anidb_id INTEGER PRIMARY KEY, tvdb_id INTEGER, tmdb_id INTEGER, + season_offset INTEGER, episode_offset INTEGER NOT NULL DEFAULT 0 + );", + ) + .unwrap(); + // Mirrors real data observed for tvdb_id 366263: one TVDB season + // stitched from four AniDB cours starting at absolute eps 1/15/27/37. + for (anidb_id, offset) in [(1, 0), (2, 14), (3, 26), (4, 36)] { + conn.execute( + "INSERT INTO anime_mapping (anidb_id, tvdb_id, season_offset, episode_offset) + VALUES (?1, 366263, 1, ?2)", + params![anidb_id, offset], + ) + .unwrap(); + } + conn + } + + #[test] + fn resolves_first_cour() { + let conn = seeded_conn(); + assert_eq!( + resolve_absolute_episode(&conn, 366263, 5).unwrap(), + Some((1, 5)) + ); + } + + #[test] + fn resolves_later_cour_using_its_offset() { + let conn = seeded_conn(); + // absolute ep 20 falls in the cour starting at 15 (offset 14) + assert_eq!( + resolve_absolute_episode(&conn, 366263, 20).unwrap(), + Some((1, 6)) + ); + } + + #[test] + fn returns_none_for_unmapped_series() { + let conn = seeded_conn(); + assert_eq!(resolve_absolute_episode(&conn, 999999, 1).unwrap(), None); + } +} diff --git a/breadarrd/src/metadata/mod.rs b/breadarrd/src/metadata/mod.rs new file mode 100644 index 0000000..e5c4b25 --- /dev/null +++ b/breadarrd/src/metadata/mod.rs @@ -0,0 +1,148 @@ +pub mod anime_map; +pub mod tmdb; +pub mod tvdb; + +use std::collections::HashSet; + +use anyhow::Result; +use rusqlite::{params, Connection}; + +#[derive(Debug, Clone, PartialEq)] +pub struct SeriesSearchResult { + pub external_id: String, + pub name: String, + pub year: Option, + pub aliases: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct MovieSearchResult { + pub external_id: String, + pub title: String, + pub year: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct EpisodeInfo { + pub season_number: u32, + pub episode_number: u32, + pub absolute_number: Option, + pub title: Option, + pub air_date: Option, +} + +/// Inserts a series and its full episode list into the library, using a +/// TVDB series id as the source of episode data. Returns the new +/// `media_item.id`. +/// +/// Split into a fetch step and a write step (see [`insert_series`]) rather +/// than one function that both awaits and holds `conn`, because a caller +/// using `Mutex` (e.g. an axum handler) can't hold a sync +/// `MutexGuard` across an `.await` point — this convenience wrapper is only +/// safe for callers with an owned, unshared `Connection` (e.g. the debug +/// CLI commands). +pub async fn add_series( + conn: &Connection, + tvdb: &tvdb::TvdbClient, + tvdb_series_id: &str, + title: &str, + year: Option, + aliases: &[String], + root_folder: &str, + quality_profile_id: i64, +) -> Result { + let episodes = tvdb.episodes(tvdb_series_id).await?; + insert_series( + conn, + tvdb_series_id, + title, + year, + aliases, + root_folder, + quality_profile_id, + &episodes, + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn insert_series( + conn: &Connection, + tvdb_series_id: &str, + title: &str, + year: Option, + aliases: &[String], + root_folder: &str, + quality_profile_id: i64, + episodes: &[EpisodeInfo], +) -> Result { + conn.execute( + "INSERT INTO media_item (kind, title, year, tvdb_id, monitored, quality_profile_id, root_folder) + VALUES ('series', ?1, ?2, ?3, 1, ?4, ?5)", + params![ + title, + year, + tvdb_series_id.parse::().ok(), + quality_profile_id, + root_folder + ], + )?; + let media_item_id = conn.last_insert_rowid(); + + for alias in aliases { + conn.execute( + "INSERT INTO alias (media_item_id, text, source) VALUES (?1, ?2, 'tvdb')", + params![media_item_id, alias], + )?; + } + + let mut seasons_seen = HashSet::new(); + for ep in episodes { + if seasons_seen.insert(ep.season_number) { + conn.execute( + "INSERT OR IGNORE INTO season (media_item_id, season_number, monitored) VALUES (?1, ?2, 1)", + params![media_item_id, ep.season_number], + )?; + } + conn.execute( + "INSERT OR IGNORE INTO episode + (media_item_id, season_number, episode_number, absolute_number, title, air_date, monitored, has_file) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, 0)", + params![ + media_item_id, + ep.season_number, + ep.episode_number, + ep.absolute_number, + ep.title, + ep.air_date + ], + )?; + } + + Ok(media_item_id) +} + +/// Inserts a movie into the library. No fetch step needed (unlike +/// `add_series`/`insert_series`) — TMDB's movie search result already +/// carries everything a movie needs (title, year); there's no separate +/// episode-list call the way a series has. +pub fn insert_movie( + conn: &Connection, + tmdb_movie_id: &str, + title: &str, + year: Option, + root_folder: &str, + quality_profile_id: i64, +) -> Result { + conn.execute( + "INSERT INTO media_item (kind, title, year, tmdb_id, monitored, quality_profile_id, root_folder) + VALUES ('movie', ?1, ?2, ?3, 1, ?4, ?5)", + params![ + title, + year, + tmdb_movie_id.parse::().ok(), + quality_profile_id, + root_folder + ], + )?; + Ok(conn.last_insert_rowid()) +} diff --git a/breadarrd/src/metadata/tmdb.rs b/breadarrd/src/metadata/tmdb.rs new file mode 100644 index 0000000..f5bc496 --- /dev/null +++ b/breadarrd/src/metadata/tmdb.rs @@ -0,0 +1,145 @@ +use anyhow::{Context, Result}; +use serde::Deserialize; + +use super::{EpisodeInfo, MovieSearchResult, SeriesSearchResult}; + +pub struct TmdbClient { + bearer_token: String, + client: reqwest::Client, +} + +impl TmdbClient { + pub fn new(bearer_token: impl Into) -> Self { + Self { + bearer_token: bearer_token.into(), + // No total timeout is reqwest's default — the background loop + // holds the DB mutex across calls into this client, so a + // stalled connection would hang the whole daemon. + client: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("reqwest client build"), + } + } + + pub async fn search_tv(&self, query: &str) -> Result> { + #[derive(Deserialize)] + struct SearchResponse { + results: Vec, + } + #[derive(Deserialize)] + struct TvItem { + id: u64, + name: String, + first_air_date: Option, + } + + let resp: SearchResponse = self + .client + .get("https://api.themoviedb.org/3/search/tv") + .bearer_auth(&self.bearer_token) + .query(&[("query", query)]) + .send() + .await + .context("tmdb tv search request failed")? + .error_for_status() + .context("tmdb tv search returned an error status")? + .json() + .await + .context("tmdb tv search response was not valid JSON")?; + + Ok(resp + .results + .into_iter() + .map(|item| SeriesSearchResult { + external_id: item.id.to_string(), + name: item.name, + year: year_from_date(item.first_air_date.as_deref()), + aliases: Vec::new(), + }) + .collect()) + } + + pub async fn search_movie(&self, query: &str) -> Result> { + #[derive(Deserialize)] + struct SearchResponse { + results: Vec, + } + #[derive(Deserialize)] + struct MovieItem { + id: u64, + title: String, + release_date: Option, + } + + let resp: SearchResponse = self + .client + .get("https://api.themoviedb.org/3/search/movie") + .bearer_auth(&self.bearer_token) + .query(&[("query", query)]) + .send() + .await + .context("tmdb movie search request failed")? + .error_for_status() + .context("tmdb movie search returned an error status")? + .json() + .await + .context("tmdb movie search response was not valid JSON")?; + + Ok(resp + .results + .into_iter() + .map(|item| MovieSearchResult { + external_id: item.id.to_string(), + title: item.title, + year: year_from_date(item.release_date.as_deref()), + }) + .collect()) + } + + pub async fn tv_season_episodes(&self, tv_id: u64, season: u32) -> Result> { + #[derive(Deserialize)] + struct SeasonResponse { + #[serde(default)] + episodes: Vec, + } + #[derive(Deserialize)] + struct EpisodeItem { + season_number: u32, + episode_number: u32, + name: Option, + air_date: Option, + } + + let resp: SeasonResponse = self + .client + .get(format!( + "https://api.themoviedb.org/3/tv/{tv_id}/season/{season}" + )) + .bearer_auth(&self.bearer_token) + .send() + .await + .context("tmdb season request failed")? + .error_for_status() + .context("tmdb season returned an error status")? + .json() + .await + .context("tmdb season response was not valid JSON")?; + + Ok(resp + .episodes + .into_iter() + .map(|e| EpisodeInfo { + season_number: e.season_number, + episode_number: e.episode_number, + absolute_number: None, + title: e.name, + air_date: e.air_date, + }) + .collect()) + } +} + +fn year_from_date(date: Option<&str>) -> Option { + date.and_then(|d| d.get(0..4)).and_then(|y| y.parse().ok()) +} diff --git a/breadarrd/src/metadata/tvdb.rs b/breadarrd/src/metadata/tvdb.rs new file mode 100644 index 0000000..914022e --- /dev/null +++ b/breadarrd/src/metadata/tvdb.rs @@ -0,0 +1,162 @@ +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use serde::Deserialize; + +use super::{EpisodeInfo, SeriesSearchResult}; + +/// TVDB v4 JWTs are valid for roughly a month; refresh well before that so +/// clock skew or early invalidation on their end doesn't strand us. +const TOKEN_TTL: Duration = Duration::from_secs(20 * 60 * 60); + +pub struct TvdbClient { + api_key: String, + client: reqwest::Client, + token: Mutex>, +} + +impl TvdbClient { + pub fn new(api_key: impl Into) -> Self { + Self { + api_key: api_key.into(), + // No total timeout is reqwest's default — the background loop + // holds the DB mutex across calls into this client, so a + // stalled connection would hang the whole daemon. + client: reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .expect("reqwest client build"), + token: Mutex::new(None), + } + } + + async fn token(&self) -> Result { + let cached = self.token.lock().unwrap().clone(); + if let Some((token, obtained_at)) = cached { + if obtained_at.elapsed() < TOKEN_TTL { + return Ok(token); + } + } + + #[derive(Deserialize)] + struct LoginResponse { + data: LoginData, + } + #[derive(Deserialize)] + struct LoginData { + token: String, + } + + let resp: LoginResponse = self + .client + .post("https://api4.thetvdb.com/v4/login") + .json(&serde_json::json!({ "apikey": self.api_key })) + .send() + .await + .context("tvdb login request failed")? + .error_for_status() + .context("tvdb login returned an error status")? + .json() + .await + .context("tvdb login response was not valid JSON")?; + + *self.token.lock().unwrap() = Some((resp.data.token.clone(), Instant::now())); + Ok(resp.data.token) + } + + pub async fn search_series(&self, query: &str) -> Result> { + let token = self.token().await?; + + #[derive(Deserialize)] + struct SearchResponse { + data: Vec, + } + #[derive(Deserialize)] + struct SearchItem { + tvdb_id: Option, + name: Option, + year: Option, + #[serde(default)] + aliases: Vec, + } + + let resp: SearchResponse = self + .client + .get("https://api4.thetvdb.com/v4/search") + .bearer_auth(token) + .query(&[("query", query), ("type", "series")]) + .send() + .await + .context("tvdb search request failed")? + .error_for_status() + .context("tvdb search returned an error status")? + .json() + .await + .context("tvdb search response was not valid JSON")?; + + Ok(resp + .data + .into_iter() + .filter_map(|item| { + Some(SeriesSearchResult { + external_id: item.tvdb_id?, + name: item.name?, + year: item.year.and_then(|y| y.parse().ok()), + aliases: item.aliases, + }) + }) + .collect()) + } + + pub async fn episodes(&self, series_id: &str) -> Result> { + let token = self.token().await?; + + #[derive(Deserialize)] + struct EpisodesResponse { + data: EpisodesData, + } + #[derive(Deserialize)] + struct EpisodesData { + episodes: Vec, + } + #[derive(Deserialize)] + struct EpisodeItem { + #[serde(rename = "seasonNumber")] + season_number: u32, + number: u32, + #[serde(rename = "absoluteNumber")] + absolute_number: Option, + name: Option, + aired: Option, + } + + let resp: EpisodesResponse = self + .client + .get(format!( + "https://api4.thetvdb.com/v4/series/{series_id}/episodes/default" + )) + .bearer_auth(token) + .send() + .await + .context("tvdb episodes request failed")? + .error_for_status() + .context("tvdb episodes returned an error status")? + .json() + .await + .context("tvdb episodes response was not valid JSON")?; + + Ok(resp + .data + .episodes + .into_iter() + .map(|e| EpisodeInfo { + season_number: e.season_number, + episode_number: e.number, + absolute_number: e.absolute_number.filter(|&n| n != 0), + title: e.name, + air_date: e.aired, + }) + .collect()) + } +} diff --git a/breadarrd/src/notify.rs b/breadarrd/src/notify.rs new file mode 100644 index 0000000..470d0d7 --- /dev/null +++ b/breadarrd/src/notify.rs @@ -0,0 +1,70 @@ +use anyhow::Result; +use serde::Serialize; + +#[derive(Serialize)] +struct Payload<'a> { + title: &'a str, + message: &'a str, +} + +/// Best-effort push notification — failures are logged, never propagated. +/// The events this fires for (a review-queue item, a run of import +/// failures, the search loop halting) already have a durable home in +/// `event_history`/the TUI; the notification is a convenience nudge on top +/// of that, not the record of truth, so it should never be able to fail an +/// otherwise-successful grab/import/search cycle. +pub struct Notifier { + client: reqwest::Client, + webhook_url: String, +} + +impl Notifier { + /// Returns `None` when `webhook_url` is empty — callers hold an + /// `Option` and simply skip notifying rather than every call + /// site needing its own empty-string check. + pub fn new(webhook_url: &str) -> Option { + if webhook_url.is_empty() { + return None; + } + Some(Self { + client: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .expect("reqwest client build"), + webhook_url: webhook_url.to_string(), + }) + } + + pub async fn send(&self, title: &str, message: &str) { + let result = self + .client + .post(&self.webhook_url) + .json(&Payload { title, message }) + .send() + .await; + match result { + Ok(resp) if !resp.status().is_success() => { + tracing::warn!(status = %resp.status(), "notification webhook returned an error status"); + } + Err(e) => { + tracing::warn!(error = %e, "notification webhook request failed"); + } + Ok(_) => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_returns_none_for_an_empty_url() { + assert!(Notifier::new("").is_none()); + } + + #[test] + fn new_returns_some_for_a_configured_url() { + assert!(Notifier::new("http://localhost:5600/message?token=x").is_some()); + } +} diff --git a/breadarrd/src/parser/mod.rs b/breadarrd/src/parser/mod.rs new file mode 100644 index 0000000..b6f9c36 --- /dev/null +++ b/breadarrd/src/parser/mod.rs @@ -0,0 +1,291 @@ +mod tokens; + +use regex::Regex; +use std::sync::LazyLock; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Source { + Hdtv, + WebRip, + WebDl, + BluRay, + Remux, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Codec { + H264, + Hevc, + Av1, +} + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ParsedRelease { + pub title_raw: String, + pub title_normalized: String, + pub group: Option, + pub season: Option, + pub episode: Option, + pub absolute_episode: Option, + pub year: Option, + pub resolution: Option, + pub source: Option, + pub codec: Option, + pub bit_depth: Option, + pub container: Option, + pub is_repack: bool, +} + +pub fn parse(raw_title: &str) -> ParsedRelease { + let mut work = raw_title.to_string(); + + let group = tokens::extract_group(&work); + if group.is_some() { + work = tokens::GROUP_PREFIX_RE + .replace(&work, "") + .trim() + .to_string(); + } + + let container = tokens::extract_container(&work); + let resolution = tokens::extract_resolution(&work); + let source = tokens::extract_source(&work); + let codec = tokens::extract_codec(&work); + let bit_depth = tokens::extract_bit_depth(&work); + let is_repack = tokens::REPACK_RE.is_match(&work); + let year = tokens::extract_year(&work); + let (season, episode, absolute_episode, title_span_end) = tokens::extract_episode_info(&work); + + let title_normalized = tokens::derive_title(&work, title_span_end); + + ParsedRelease { + title_raw: raw_title.to_string(), + title_normalized, + group, + season, + episode, + absolute_episode, + year, + resolution, + source, + codec, + bit_depth, + container, + is_repack, + } +} + +pub(crate) static WS_RE: LazyLock = LazyLock::new(|| Regex::new(r"\s+").unwrap()); + +#[cfg(test)] +mod tests { + use super::*; + + const CORPUS: &[&str] = &[ + "Aime ton prochain (01-06) (Chida) (2019) [Digital-1920] [Manga FR] (PapriKa+)", + "[ANi] 小書痴的下剋上 為了成為圖書管理員不擇手段!領主的養女 - 13 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]", + "Ascendance of a Bookworm S04E11 The Gathering of Gutenberg 1080p CR WEB-DL DUAL AAC2.0 H 264-VARYG (Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen, Dual-Audio, Multi-Subs)", + "Ascendance of a Bookworm S04E12 The Winter Social Season and Debut 1080p CR WEB-DL DUAL AAC2.0 H 264-VARYG (Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen, Dual-Audio, Multi-Subs)", + "Ascendance of a Bookworm S04E13 VOSTFR 1080p WEB x264 AAC -Tsundere-Raws (CR) (Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen - Ryoushu no Youjo,Ascendance of a Bookworm: Adopted Daughter of an Archduke)", + "Ascendance of a Bookworm S04E13 VOSTFR 720p WEB x264 AAC -Tsundere-Raws (CR) (Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen - Ryoushu no Youjo,Ascendance of a Bookworm: Adopted Daughter of an Archduke)", + "Ascendance of a Bookworm S04E13 Winter Material Gathering 1080p CR WEB-DL AAC2.0 H 264-VARYG (Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen, Multi-Subs)", + "Assassin's Creed - Blade of Shao Jun (01-04) (Kurata) (2020) [Digital-1920] [Manga FR] (PapriKa+)", + "[Erai-raws] Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen - Ryushu no Youjo - 13 [1080p CR WEB-DL AVC AAC][MultiSub][18261A02]", + "[Erai-raws] Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen - Ryushu no Youjo - 13 [480p CR WEB-DL AVC AAC][MultiSub][A22FE68E]", + "[Erai-raws] Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen - Ryushu no Youjo - 13 [720p CR WEB-DL AVC AAC][MultiSub][4D409859]", + "[GM-Team][国漫][仙逆][Renegade Immortal][2023][148][AVC][GB][1080P]", + "[GM-Team][国漫][光阴之外][Beyond Time's Gaze][2025][29][GB][4K HEVC 10Bit]", + "[GM-Team][国漫][吞噬星空][Swallowed Star][2021][231][AVC][GB][1080P]", + "[GM-Team][国漫][斗破苍穹 第5季][Fights Break Sphere Ⅴ][2022][206][AVC][GB][1080P]", + "[GM-Team][国漫][斗破苍穹 第5季][Fights Break Sphere Ⅴ][2022][206][HEVC][GB][4K]", + "[GM-Team][国漫][牧神记][Tales of Qin Mu][2024][90][AVC][GB][1080P]", + "KILL BLUE S01E12 Life Paths 1080p AMZN WEB-DL MULTi DDP2.0 H 264-VARYG (Kill Ao, Multi-Audio, Multi-Subs)", + "[LoliHouse] 与奔驰于透明之夜的你,谈一场看不见的恋爱。 / 透明な夜に駆ける君と、目に見えない恋をした。 / KakeKoi - 01 [WebRip 1080p HEVC-10bit AAC][简繁内封字幕]", + "RILAKKUMA S01E13 1080p CR WEB-DL MULTi AAC2.0 H 264-VARYG (Multi-Audio, Multi-Subs)", + "[RUBaDUB] Kaiju No. 8 (S1 Complete) (1080p) (Dual Audio)", + "[SubsPlease] Honzuki no Gekokujou S4 - 13 (1080p) [A4FE0990].mkv", + "[SubsPlease] Honzuki no Gekokujou S4 - 13 (480p) [17152F60].mkv", + "[SubsPlease] Honzuki no Gekokujou S4 - 13 (720p) [97744C2D].mkv", + "That Time I Got Reincarnated as a Slime S04E12 Tempest Evolves 1080p CR WEB-DL DUAL AAC2.0 H 264-VARYG (Tensei shitara Slime Datta Ken, Dual-Audio, Multi-Subs)", + "The Drops of God S01E12 Take the Distant Path 1080p CR WEB-DL DUAL AAC2.0 H 264-VARYG (Kami no Shizuku, Dual-Audio, Multi-Subs)", + "[ToonsHub] Ascendance of a Bookworm S04E11 1080p CR WEB-DL DUAL AAC2.0 H.264 (Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen, Dual-Audio, Multi-Subs)", + "[ToonsHub] Ascendance of a Bookworm S04E12 1080p CR WEB-DL DUAL AAC2.0 H.264 (Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen, Dual-Audio, Multi-Subs)", + "[ToonsHub] Ascendance of a Bookworm S04E13 1080p CR WEB-DL AAC2.0 H.264 (Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen, Multi-Subs)", + "[ToonsHub] Honzuki no Gekokujou S04E13 1080p AMZN WEB-DL DDP2.0 H.264 (Ascendance of a Bookworm Side Story)", + "[Yameii] Ascendance of a Bookworm - S04E11 [English Dub] [CR WEB-DL 1080p H264 AAC] [8ACE7B72] (Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan o Erande Iraremasen - Ryoushu no Youjo | Adopted Daughter of an Archduke)", + "[Yameii] Ascendance of a Bookworm - S04E12 [English Dub] [CR WEB-DL 1080p H264 AAC] [ACE19921] (Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan o Erande Iraremasen - Ryoushu no Youjo | Adopted Daughter of an Archduke)", + "[桜都字幕组] 入间同学入魔了 第四季 / Mairimashita! Iruma-kun (2026) [13][1080P][简体内嵌]", + "[桜都字幕组] 入间同学入魔了 第四季 / Mairimashita! Iruma-kun (2026) [13][1080P][简繁内封]", + "[桜都字幕组] 入间同学入魔了 第四季 / Mairimashita! Iruma-kun (2026) [13][1080P][繁体内嵌]", + "[桜都字幕组] 入间同学入魔了 第四季 / Mairimashita! Iruma-kun (2026) [14][1080P][简繁内封]", + ]; + + #[test] + fn parses_standard_sxxexx_with_group_and_quality_chain() { + let p = parse("Ascendance of a Bookworm S04E11 The Gathering of Gutenberg 1080p CR WEB-DL DUAL AAC2.0 H 264-VARYG (Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen, Dual-Audio, Multi-Subs)"); + assert_eq!(p.season, Some(4)); + assert_eq!(p.episode, Some(11)); + assert_eq!(p.resolution, Some(1080)); + assert_eq!(p.source, Some(Source::WebDl)); + assert_eq!(p.codec, Some(Codec::H264)); + assert_eq!(p.title_normalized, "Ascendance of a Bookworm"); + } + + #[test] + fn parses_subsplease_dash_episode_with_group_and_hash() { + let p = parse("[SubsPlease] Honzuki no Gekokujou S4 - 13 (1080p) [A4FE0990].mkv"); + assert_eq!(p.group.as_deref(), Some("SubsPlease")); + assert_eq!(p.season, Some(4)); + assert_eq!(p.episode, Some(13)); + assert_eq!(p.resolution, Some(1080)); + assert_eq!(p.container.as_deref(), Some("mkv")); + assert_eq!(p.title_normalized, "Honzuki no Gekokujou"); + } + + #[test] + fn parses_erai_raws_bracket_quality_block() { + let p = parse("[Erai-raws] Honzuki no Gekokujou: Shisho ni Naru Tame ni wa Shudan wo Erandeiraremasen - Ryushu no Youjo - 13 [1080p CR WEB-DL AVC AAC][MultiSub][18261A02]"); + assert_eq!(p.group.as_deref(), Some("Erai-raws")); + assert_eq!(p.season, None); + assert_eq!(p.episode, Some(13)); + assert_eq!(p.resolution, Some(1080)); + assert_eq!(p.source, Some(Source::WebDl)); + } + + #[test] + fn parses_lolihouse_webrip_hevc_10bit() { + let p = parse("[LoliHouse] Some Title - 01 [WebRip 1080p HEVC-10bit AAC][Subs]"); + assert_eq!(p.group.as_deref(), Some("LoliHouse")); + assert_eq!(p.episode, Some(1)); + assert_eq!(p.source, Some(Source::WebRip)); + assert_eq!(p.codec, Some(Codec::Hevc)); + assert_eq!(p.bit_depth, Some(10)); + } + + #[test] + fn parses_season_pack_no_episode() { + let p = parse("[RUBaDUB] Kaiju No. 8 (S1 Complete) (1080p) (Dual Audio)"); + assert_eq!(p.group.as_deref(), Some("RUBaDUB")); + assert_eq!(p.season, Some(1)); + assert_eq!(p.episode, None); + assert_eq!(p.resolution, Some(1080)); + } + + #[test] + fn parses_yameii_dash_sxxexx_with_english_dub_tag() { + let p = parse("[Yameii] Ascendance of a Bookworm - S04E11 [English Dub] [CR WEB-DL 1080p H264 AAC] [8ACE7B72] (Honzuki no Gekokujou)"); + assert_eq!(p.group.as_deref(), Some("Yameii")); + assert_eq!(p.season, Some(4)); + assert_eq!(p.episode, Some(11)); + assert_eq!(p.codec, Some(Codec::H264)); + } + + #[test] + fn parses_year_and_bare_episode_number() { + let p = parse("[GM-Team][Renegade Immortal][2023][148][AVC][GB][1080P]"); + assert_eq!(p.year, Some(2023)); + assert_eq!(p.resolution, Some(1080)); + assert_eq!(p.codec, Some(Codec::H264).or(Some(Codec::H264))); // AVC == H264 family + } + + /// A batch of real, unmodified nyaa.si release titles pulled live + /// across several categories — not hand-picked for parseability. Not a + /// correctness check (no ground truth for the messier CJK/bracket-only + /// shapes), just a robustness net: parse() must never panic, and on + /// the common WEB-DL/anime shapes it should extract *something*. + #[test] + fn does_not_panic_on_real_corpus() { + let mut resolution_hits = 0; + for title in CORPUS { + let p = parse(title); + if p.resolution.is_some() { + resolution_hits += 1; + } + assert!(!p.title_normalized.is_empty(), "empty title for {title:?}"); + } + // Most of this corpus carries an explicit resolution tag; a low hit + // rate would mean the resolution regex regressed, not just that a + // few CJK/manga edge cases were missed. + assert!( + resolution_hits * 2 > CORPUS.len(), + "only {resolution_hits}/{} titles yielded a resolution", + CORPUS.len() + ); + } + + #[test] + fn refuses_to_resolve_a_bracketed_batch_range_to_one_episode() { + // Previously resolved to episode 12, silently attributing the + // whole 12-episode batch torrent to one guessed episode. + let p = parse("[Judas] Some Show (01-12) [BD 1080p]"); + assert_eq!(p.episode, None); + assert_eq!(p.absolute_episode, None); + } + + #[test] + fn refuses_to_resolve_a_dash_prefixed_batch_range_to_one_episode() { + // Previously resolved to episode 1 (the first number after the + // leading dash), same underlying problem as above. + let p = parse("Show - 01-12 [1080p]"); + assert_eq!(p.episode, None); + assert_eq!(p.absolute_episode, None); + } + + #[test] + fn refuses_to_resolve_an_sxxexx_range_to_one_episode() { + // Previously resolved to S01E01 via SXXEXX_RE matching only the + // first half of the range. + let p = parse("Show S01E01-E12 1080p WEB-DL"); + assert_eq!(p.season, Some(1)); + assert_eq!(p.episode, None); + assert_eq!(p.absolute_episode, None); + } + + #[test] + fn single_episode_dash_titles_are_unaffected_by_range_detection() { + // A genuine single episode using dash notation must still resolve + // normally — only real ranges should be refused. + let p = parse("[SubsPlease] Some Show - 05 (1080p) [ABCD1234].mkv"); + assert_eq!(p.episode, Some(5)); + assert_eq!(p.absolute_episode, Some(5)); + } + + #[test] + fn extracts_a_bare_year_from_a_scene_style_movie_name() { + // Previously parsed as year=None entirely, since these releases + // never bracket the year — silently disabling movie_year_mismatch, + // the only defense against a same-named-but-wrong film, on exactly + // the naming convention TPB/1337x results actually use. + let p = parse("Dune.1984.1080p.BluRay.x264-GROUP"); + assert_eq!(p.year, Some(1984)); + + let p = parse("Dune 1984 1080p BluRay"); + assert_eq!(p.year, Some(1984)); + } + + #[test] + fn does_not_mistake_a_year_titled_movie_for_a_bare_year() { + // "1917" and "2012" are real movie titles — a bare year at the very + // start of the string must not be treated as a release year, or + // the title would be mistaken for empty. + let p = parse("1917 1080p BluRay x264-GROUP"); + assert_eq!(p.year, None); + } + + #[test] + fn brackets_still_take_priority_over_a_coincidental_bare_year() { + let p = parse("Some.Show.2024.S01E01.(2023).1080p.WEB-DL"); + assert_eq!(p.year, Some(2023)); + } + + #[test] + fn does_not_panic_on_unparsable_manga_release() { + // Not a video release at all — should degrade gracefully, not crash. + let p = + parse("Aime ton prochain (01-06) (Chida) (2019) [Digital-1920] [Manga FR] (PapriKa+)"); + assert_eq!(p.year, Some(2019)); + // season/episode extraction is allowed to miss here; the important + // thing is it returns *something* rather than panicking. + let _ = p.season; + } +} diff --git a/breadarrd/src/parser/tokens.rs b/breadarrd/src/parser/tokens.rs new file mode 100644 index 0000000..2c722ee --- /dev/null +++ b/breadarrd/src/parser/tokens.rs @@ -0,0 +1,206 @@ +use std::sync::LazyLock; + +use regex::Regex; + +use super::{Codec, Source, WS_RE}; + +pub(super) static GROUP_PREFIX_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^\[[^\]]+\]\s*").unwrap()); + +static LEADING_GROUP_RE: LazyLock = LazyLock::new(|| Regex::new(r"^\[([^\]]+)\]").unwrap()); + +static CONTAINER_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)\.(mkv|mp4|avi)\b").unwrap()); + +static RESOLUTION_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)\b(2160p|1080p|720p|480p|4k)\b").unwrap()); + +static SOURCE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)\b(BDRemux|Remux|Blu-?Ray|BDRip|WEB-?DL|WEBRip|HDTV)\b").unwrap() +}); + +static CODEC_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)\b(AV1|HEVC|H\.?\s?265|x265|H\.?\s?264|x264|AVC)\b").unwrap() +}); + +static BIT_DEPTH_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)\b(8|10)-?bit\b").unwrap()); + +pub(super) static REPACK_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)\b(REPACK|PROPER)\b").unwrap()); + +static YEAR_RE: LazyLock = LazyLock::new(|| Regex::new(r"[(\[](19|20)\d{2}[)\]]").unwrap()); +// Scene-style releases ("Dune.1984.1080p.BluRay.x264-GROUP") carry the year +// bare, with no surrounding brackets — `YEAR_RE` above never matches these +// at all, which meant `movie_year_mismatch` (the only defense against a +// same-named-but-wrong film once a title auto-matches) silently never fired +// on exactly the naming convention TPB/1337x results actually use. +static BARE_YEAR_RE: LazyLock = + LazyLock::new(|| Regex::new(r"\b((?:19|20)\d{2})\b").unwrap()); + +static SXXEXX_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)\bS(\d{1,2})E(\d{1,3})\b").unwrap()); +static SXX_DASH_EP_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)\bS(\d{1,2})\s*-\s*(\d{1,3})\b").unwrap()); +static SEASON_PACK_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)\(S?(\d{1,2})\s*Complete\)").unwrap()); +static DASH_EPISODE_RE: LazyLock = LazyLock::new(|| Regex::new(r"-\s*(\d{1,3})\b").unwrap()); + +// A batch/season-pack release covering many episodes in one torrent. +// `SXXEXX_RE`/`DASH_EPISODE_RE` above would otherwise happily resolve one +// of these to a single arbitrary episode number (verified live: +// "Show (01-12) [1080p]" resolves to episode 12, "Show - 01-12 [1080p]" +// resolves to episode 1, "Show S01E01-E12" resolves to S01E01) — the +// importer then grabs the whole multi-episode torrent, picks whichever +// file happens to be largest, and files it under that one guessed episode +// while silently discarding the rest. `looks_like_episode_range` below is +// checked first so these get refused rather than mis-resolved. +static BATCH_WORD_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)\b(batch|complete)\b").unwrap()); +// "S01E01-E12" / "S01E01-12": no word-boundary exists between the digits +// and letters in a run like "S01E01-E12" (letters and digits are both +// \w), so a boundary-anchored generic range pattern can't find this — +// needs its own literal S..E..-..E?.. shape. +static SXX_EPISODE_RANGE_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)\bS(\d{1,2})E(\d{1,3})\s*-\s*E?(\d{1,3})\b").unwrap()); +// Bare numeric ranges: "(01-12)", "01-12", "01~12". Requires the second +// number to be strictly larger than the first (a real episode range +// always counts up) so this doesn't fire on, say, an unrelated dash +// elsewhere in the title with a smaller trailing number. +static BARE_EPISODE_RANGE_RE: LazyLock = + LazyLock::new(|| Regex::new(r"\b(\d{1,3})\s*[-~]\s*(\d{1,3})\b").unwrap()); + +pub(super) fn looks_like_episode_range(s: &str) -> bool { + if BATCH_WORD_RE.is_match(s) || SXX_EPISODE_RANGE_RE.is_match(s) { + return true; + } + BARE_EPISODE_RANGE_RE.captures(s).is_some_and(|c| { + let a: u32 = c[1].parse().unwrap_or(0); + let b: u32 = c[2].parse().unwrap_or(0); + b > a + }) +} + +pub(super) fn extract_group(s: &str) -> Option { + LEADING_GROUP_RE + .captures(s) + .map(|c| c[1].trim().to_string()) +} + +pub(super) fn extract_container(s: &str) -> Option { + CONTAINER_RE.captures(s).map(|c| c[1].to_lowercase()) +} + +pub(super) fn extract_resolution(s: &str) -> Option { + let m = RESOLUTION_RE.captures(s)?; + let token = m[1].to_lowercase(); + if token == "4k" { + return Some(2160); + } + token.trim_end_matches('p').parse().ok() +} + +pub(super) fn extract_source(s: &str) -> Option { + let token = SOURCE_RE.captures(s)?[1].to_lowercase(); + Some(match token.as_str() { + "bdremux" | "remux" => Source::Remux, + t if t.replace('-', "") == "bluray" => Source::BluRay, + "bdrip" => Source::BluRay, + t if t.replace('-', "") == "webdl" => Source::WebDl, + "webrip" => Source::WebRip, + "hdtv" => Source::Hdtv, + _ => return None, + }) +} + +pub(super) fn extract_codec(s: &str) -> Option { + let token = CODEC_RE.captures(s)?[1] + .to_lowercase() + .replace([' ', '.'], ""); + Some(match token.as_str() { + "av1" => Codec::Av1, + "hevc" | "h265" | "x265" => Codec::Hevc, + "h264" | "x264" | "avc" => Codec::H264, + _ => return None, + }) +} + +pub(super) fn extract_bit_depth(s: &str) -> Option { + BIT_DEPTH_RE.captures(s)?[1].parse().ok() +} + +pub(super) fn extract_year(s: &str) -> Option { + if let Some(m) = YEAR_RE.find(s) { + return s[m.start() + 1..m.end() - 1].parse().ok(); + } + // Bare-year fallback, but never at the very start of the string — a + // movie literally titled after a year ("1917", "2012") would otherwise + // have its own title mistaken for a year with nothing left over. Same + // guard `library_scan.rs`'s `FOLDER_BARE_YEAR_RE` uses. + let m = BARE_YEAR_RE.find(s)?; + if m.start() == 0 { + return None; + } + s[m.start()..m.end()].parse().ok() +} + +/// Returns (season, episode, absolute_episode, title_span_end) — the last +/// element is the byte offset in `s` where the episode/season token (or, +/// failing that, the first quality marker) begins, used to slice out the +/// title portion. +pub(super) fn extract_episode_info(s: &str) -> (Option, Option, Option, usize) { + if looks_like_episode_range(s) { + // Season is still useful to surface (e.g. for display/logging) + // when it's unambiguous, but episode/absolute_episode are + // deliberately left unresolved — see `looks_like_episode_range`'s + // doc comment for why guessing one is actively harmful here. + let season = SXXEXX_RE + .captures(s) + .and_then(|c| c[1].parse().ok()) + .or_else(|| SEASON_PACK_RE.captures(s).and_then(|c| c[1].parse().ok())); + let end = first_quality_marker(s).unwrap_or(s.len()); + return (season, None, None, end); + } + if let Some(c) = SXXEXX_RE.captures(s) { + let season = c[1].parse().ok(); + let episode = c[2].parse().ok(); + return (season, episode, None, c.get(0).unwrap().start()); + } + if let Some(c) = SXX_DASH_EP_RE.captures(s) { + let season = c[1].parse().ok(); + let episode = c[2].parse().ok(); + return (season, episode, None, c.get(0).unwrap().start()); + } + if let Some(c) = SEASON_PACK_RE.captures(s) { + let season = c[1].parse().ok(); + return (season, None, None, c.get(0).unwrap().start()); + } + if let Some(c) = DASH_EPISODE_RE.captures(s) { + let episode: Option = c[1].parse().ok(); + return (None, episode, episode, c.get(0).unwrap().start()); + } + + let end = first_quality_marker(s).unwrap_or(s.len()); + (None, None, None, end) +} + +fn first_quality_marker(s: &str) -> Option { + [ + RESOLUTION_RE.find(s).map(|m| m.start()), + SOURCE_RE.find(s).map(|m| m.start()), + CODEC_RE.find(s).map(|m| m.start()), + YEAR_RE.find(s).map(|m| m.start()), + ] + .into_iter() + .flatten() + .min() +} + +pub(super) fn derive_title(s: &str, span_end: usize) -> String { + let candidate = &s[..span_end.min(s.len())]; + let trimmed = candidate + .trim() + .trim_end_matches(['-', ':', '(']) + .trim_end_matches(char::is_whitespace); + WS_RE.replace_all(trimmed, " ").trim().to_string() +} diff --git a/breadarrd/src/qbit/mod.rs b/breadarrd/src/qbit/mod.rs new file mode 100644 index 0000000..bf46181 --- /dev/null +++ b/breadarrd/src/qbit/mod.rs @@ -0,0 +1,265 @@ +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use std::sync::LazyLock; + +static BTIH_RE: LazyLock = + LazyLock::new(|| regex::Regex::new(r"(?i)urn:btih:([0-9a-f]{40}|[2-7a-z]{32})").unwrap()); + +/// Pulls a magnet link's own infohash out of it directly — no need to ask +/// qBittorrent to correlate anything when the caller already handed us the +/// hash. Returns `None` for anything that isn't a magnet URI (e.g. nyaa's +/// `.torrent`-file download URLs), which callers fall back to polling for. +/// Lowercased to match the casing qBittorrent's own API always returns. +pub(crate) fn extract_btih(link: &str) -> Option { + BTIH_RE.captures(link).map(|c| c[1].to_lowercase()) +} + +/// qBittorrent rejected a magnet outright (HTTP 200 with body "Fails.") — +/// deterministic for a given hash (a dead/unreachable torrent, a malformed +/// magnet), unlike a network-level failure. A distinct type so callers can +/// `downcast_ref` and treat it differently from a transient error: retrying +/// the exact same hash next cycle would just fail identically forever, +/// where a real network blip is worth retrying. +#[derive(Debug)] +pub struct MagnetRejected { + pub body: String, +} + +impl std::fmt::Display for MagnetRejected { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "qbit rejected the magnet: body={:?}", self.body) + } +} + +impl std::error::Error for MagnetRejected {} + +pub struct QbitClient { + base_url: String, + client: reqwest::Client, + /// Credentials from the last successful `login()`, kept so a session + /// that's expired mid-run (a qBittorrent container restarting under it + /// is common in a Docker-based setup) can be silently re-established + /// instead of every subsequent call failing until breadarrd itself is + /// restarted. + credentials: tokio::sync::RwLock>, + /// Serializes the add-then-correlate-hash sequence (see + /// `scheduler::grab_and_capture_hash`) across every caller — the + /// background grab loop and the API's review-approve handler both add + /// torrents against the same category and can run concurrently. + /// Without this, two adds interleaved between one caller's before/after + /// `torrents/info` snapshots let the *other* caller's torrent look like + /// "the new one," recording the wrong hash against the wrong release + /// (verified as a real risk, not just theoretical, during the Fable 5 + /// audit — see the caller for the fuller writeup). + grab_lock: tokio::sync::Mutex<()>, +} + +#[derive(Debug, Deserialize)] +pub struct TorrentInfo { + pub hash: String, + pub name: String, + pub state: String, + pub progress: f64, + pub save_path: String, + /// Full path to the torrent's content (file or directory root) — + /// qBittorrent resolves this for us, so importers don't need to guess + /// at how save_path and name combine for a given torrent. + pub content_path: String, +} + +impl QbitClient { + pub fn new(base_url: impl Into) -> Result { + // No total timeout is reqwest's default — the background loop holds + // the DB mutex across calls into this client, so a stalled + // connection would hang the whole daemon. + let client = reqwest::Client::builder() + .cookie_store(true) + .timeout(std::time::Duration::from_secs(30)) + .build()?; + Ok(Self { + base_url: base_url.into(), + client, + credentials: tokio::sync::RwLock::new(None), + grab_lock: tokio::sync::Mutex::new(()), + }) + } + + pub async fn login(&self, username: &str, password: &str) -> Result<()> { + self.do_login(username, password).await?; + *self.credentials.write().await = Some((username.to_string(), password.to_string())); + Ok(()) + } + + async fn do_login(&self, username: &str, password: &str) -> Result<()> { + let resp = self + .client + .post(format!("{}/api/v2/auth/login", self.base_url)) + .form(&[("username", username), ("password", password)]) + .send() + .await + .context("qbit login request failed")?; + + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + if !status.is_success() || body.trim() != "Ok." { + bail!("qbit login failed: status={status} body={body:?}"); + } + Ok(()) + } + + /// Re-authenticates using the credentials from the last successful + /// `login()`, if any. Returns `true` on success so callers know it's + /// worth retrying the request that got a 403 in the first place. + async fn try_reauth(&self) -> bool { + let creds = self.credentials.read().await.clone(); + let Some((username, password)) = creds else { + return false; + }; + self.do_login(&username, &password).await.is_ok() + } + + pub async fn add_magnet(&self, magnet: &str, category: &str) -> Result<()> { + for attempt in 0..2 { + let form = reqwest::multipart::Form::new() + .text("urls", magnet.to_string()) + .text("category", category.to_string()); + + let resp = self + .client + .post(format!("{}/api/v2/torrents/add", self.base_url)) + .multipart(form) + .send() + .await + .context("qbit add-torrent request failed")?; + + let status = resp.status(); + if status == reqwest::StatusCode::FORBIDDEN && attempt == 0 && self.try_reauth().await { + continue; + } + let body = resp.text().await.unwrap_or_default(); + if !status.is_success() { + bail!("qbit add-torrent failed: status={status} body={body:?}"); + } + // qBittorrent's add-torrent endpoint returns HTTP 200 even when + // it rejects the magnet outright (a dead/malformed hash, one it + // already knows is unreachable) — the *only* signal is the + // response body text ("Ok." vs "Fails."). Without this check a + // rejected magnet looks identical to a real success: the caller + // records a `release` row as grabbed and nothing ever + // downloads, silently and permanently (verified live — this + // happened for a real release). + if body.trim() != "Ok." { + return Err(anyhow::Error::new(MagnetRejected { body })); + } + return Ok(()); + } + unreachable!("loop always returns or bails on its second iteration") + } + + pub async fn list_torrents(&self, category: Option<&str>) -> Result> { + for attempt in 0..2 { + let mut req = self + .client + .get(format!("{}/api/v2/torrents/info", self.base_url)); + if let Some(category) = category { + req = req.query(&[("category", category)]); + } + + let resp = req.send().await.context("qbit list-torrents failed")?; + let status = resp.status(); + if status == reqwest::StatusCode::FORBIDDEN && attempt == 0 && self.try_reauth().await { + continue; + } + if !status.is_success() { + bail!("qbit list-torrents failed: status={status}"); + } + return Ok(resp.json().await?); + } + unreachable!("loop always returns or bails on its second iteration") + } + + /// Sends a POST form request, retrying once after a silent re-login if + /// the session had expired (403). Shared by the file-relocation methods + /// below — `add_magnet`/`list_torrents` predate this and are left as + /// they are rather than churned for the sake of it. + async fn post_form(&self, path: &str, form: &[(&str, &str)]) -> Result { + for attempt in 0..2 { + let resp = self + .client + .post(format!("{}{path}", self.base_url)) + .form(form) + .send() + .await + .with_context(|| format!("qbit {path} request failed"))?; + let status = resp.status(); + if status == reqwest::StatusCode::FORBIDDEN && attempt == 0 && self.try_reauth().await { + continue; + } + let body = resp.text().await.unwrap_or_default(); + if !status.is_success() { + bail!("qbit {path} failed: status={status} body={body:?}"); + } + return Ok(body); + } + unreachable!("loop always returns or bails on its second iteration") + } + + /// Moves a torrent's save location — qBittorrent physically relocates + /// the underlying file(s) itself and continues seeding from the new + /// path, rather than breadarr keeping a second permanent copy purely to + /// satisfy its own import step. + /// Held for the duration of an add-then-correlate-hash sequence — see + /// `grab_lock`'s doc comment on why this needs to be process-wide, not + /// just per-call. + pub(crate) async fn lock_for_grab(&self) -> tokio::sync::MutexGuard<'_, ()> { + self.grab_lock.lock().await + } + + pub async fn set_location(&self, hash: &str, location: &str) -> Result<()> { + self.post_form( + "/api/v2/torrents/setLocation", + &[("hashes", hash), ("location", location)], + ) + .await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_hash_from_a_real_magnet() { + let link = "magnet:?xt=urn:btih:3F493B821C8B13CA2EE6DA0183B4803B187F9363&dn=Some+Show&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce"; + assert_eq!( + extract_btih(link).as_deref(), + Some("3f493b821c8b13ca2ee6da0183b4803b187f9363") + ); + } + + #[test] + fn extracts_base32_hash_from_a_magnet() { + let link = "magnet:?xt=urn:btih:jz2eqzsm3mmirrbcaacegz3czfzwxjbc&dn=Some+Show"; + assert_eq!( + extract_btih(link).as_deref(), + Some("jz2eqzsm3mmirrbcaacegz3czfzwxjbc") + ); + } + + #[test] + fn returns_none_for_a_torrent_download_url() { + assert_eq!( + extract_btih("https://nyaa.si/download/2131680.torrent"), + None + ); + } + + #[test] + fn returns_none_for_a_1337x_page_url() { + assert_eq!( + extract_btih("https://1337x.to/torrent/3740704/Some-Show/"), + None + ); + } +} diff --git a/breadarrd/src/scheduler.rs b/breadarrd/src/scheduler.rs new file mode 100644 index 0000000..3a990c6 --- /dev/null +++ b/breadarrd/src/scheduler.rs @@ -0,0 +1,3065 @@ +use anyhow::Result; +use rusqlite::{params, Connection, OptionalExtension}; + +use crate::matcher::{self, MatchOutcome, TitleMatcher}; +use crate::metadata::anime_map; +use crate::parser::{self, ParsedRelease}; +use crate::qbit::QbitClient; +use crate::scoring::{self, GateContext, GateResult, ProfileKind, QualityProfile}; +use crate::sources::{self, RawReleaseItem, ReleaseSource}; + +#[derive(Debug, Clone, PartialEq)] +pub enum ProcessOutcome { + Grabbed { + media_item_id: i64, + episode_id: Option, + /// Set when this grab was a season pack — lets a search-cycle + /// caller recognize that a single-episode target got satisfied + /// incidentally by a whole-season grab, not just by an exact + /// `episode_id` match. + season_number: Option, + score: f32, + }, + QueuedForReview, + NoMatch, + CouldNotResolveEpisode, + NotMonitoredOrAlreadyHave, + GateRejected(scoring::RejectReason), + NotBetterThanExisting, + /// Movie-only: the release's own parsed year and the monitored movie's + /// year differ by more than one — the TV path's equivalent guard is + /// season/episode resolution; movies have no such structural signal, so + /// year is the only cheap defense against a same-named-but-wrong film + /// (e.g. "Dune" 1984 vs 2021). + YearMismatch, + /// qBittorrent rejected the magnet outright (a dead hash, most likely — + /// a source's own seeder count can be stale). Deliberately an `Ok` + /// outcome, not a propagated `Err`: unlike a transient network failure, + /// the exact same hash will reject identically forever, so this needs + /// to be marked seen and moved past rather than retried — treating it + /// as a generic error once caused an infinite retry loop on a single + /// dead candidate (verified live). + MagnetRejected, + /// The torrent was (most likely) actually added to qBittorrent, but its + /// hash couldn't be correlated back — recorded as a `failed` release + /// (not `grabbed`) so it doesn't block the episode/movie from being + /// re-searched forever (a `grabbed`-with-NULL-hash row is invisible to + /// the importer's `torrent_hash IS NOT NULL` filter and never gets + /// cleaned up any other way — verified live as a real, permanent stuck + /// state before this was added). + HashCaptureFailed, + /// The release's title carries an explicit non-video format marker + /// (ebook, audiobook, comic) — a source returning something that merely + /// shares a monitored show/movie's title text, not an actual episode or + /// film. Checked before title-matching so these never reach the review + /// queue at all (verified live: nyaa's RSS feed and TPB/1337x search + /// both surface this — a general torrent search or an unscoped feed has + /// no concept of "only video releases", so an ebook titled after a show + /// embeds just as confidently as a real episode would). + NonVideoFormat, +} + +struct MediaItemRow { + id: i64, + kind: String, + tvdb_id: Option, + year: Option, + quality_profile_id: i64, +} + +fn get_media_item(conn: &Connection, id: i64) -> Result { + conn.query_row( + "SELECT id, kind, tvdb_id, year, quality_profile_id FROM media_item WHERE id = ?1", + params![id], + |row| { + Ok(MediaItemRow { + id: row.get(0)?, + kind: row.get(1)?, + tvdb_id: row.get(2)?, + year: row.get(3)?, + quality_profile_id: row.get(4)?, + }) + }, + ) + .map_err(Into::into) +} + +/// Reads `quality_profile.weights` for `quality_profile_id` and applies it +/// on top of the built-in defaults for `kind` (see +/// `QualityProfile::with_weights_override`). A missing row (a dangling +/// `quality_profile_id` shouldn't happen given the FK constraint, but this +/// is scoring, not a place to ever hard-fail a grab cycle over it) falls +/// back to exactly the hardcoded defaults, same as malformed JSON does. +fn load_quality_profile( + conn: &Connection, + quality_profile_id: i64, + kind: ProfileKind, +) -> Result { + let weights_json: Option = conn + .query_row( + "SELECT weights FROM quality_profile WHERE id = ?1", + params![quality_profile_id], + |row| row.get(0), + ) + .optional()?; + Ok(QualityProfile::with_weights_override( + kind, + weights_json.as_deref().unwrap_or(""), + )) +} + +/// True when a release's parsed episode markers indicate it's actually an +/// episode of some show, not the movie whose title it matched. +fn looks_like_episode(parsed: &ParsedRelease) -> bool { + parsed.season.is_some() || parsed.episode.is_some() || parsed.absolute_episode.is_some() +} + +/// True when a release's parsed markers indicate a whole-season or +/// multi-episode batch torrent rather than a single episode — season +/// known, episode/absolute-episode deliberately left unresolved by the +/// parser (see `parser::tokens::looks_like_episode_range`'s doc comment +/// for why guessing one episode out of a batch is actively harmful). +fn looks_like_season_pack(parsed: &ParsedRelease) -> bool { + parsed.season.is_some() && parsed.episode.is_none() && parsed.absolute_episode.is_none() +} + +/// True when a release's raw title carries an explicit non-video format +/// marker — an ebook, audiobook, or comic that happens to share a +/// monitored show/movie's title text, not an actual episode or film. A +/// plain substring check on the raw (unparsed) title, deliberately upstream +/// of the embedding matcher: these aren't "low-confidence" matches needing +/// human review, they're not video releases at all, and title-similarity +/// alone can't tell the difference (the whole point of a shared title). +fn looks_like_non_video_format(title: &str) -> bool { + const NON_VIDEO_MARKERS: &[&str] = &[ + "EPUB", + "MOBI", + "AZW", + "PDF", + "CBR", + "CBZ", + "DJVU", + "AUDIOBOOK", + "LIGHT NOVEL", + "M4B", + ]; + let upper = title.to_uppercase(); + NON_VIDEO_MARKERS.iter().any(|m| upper.contains(m)) +} + +/// True when both years are known and differ by more than one — the +/// movie-world guard against a same-named-but-wrong film (e.g. "Dune" 1984 +/// vs. 2021). `None` on either side means "not enough signal to reject." +fn movie_year_mismatch(parsed_year: Option, media_item_year: Option) -> bool { + match (parsed_year, media_item_year) { + (Some(want), Some(have)) => want.abs_diff(have as u32) > 1, + _ => false, + } +} + +/// Movie counterpart to `find_monitored_missing_episode`: true when the +/// movie is monitored, has no file yet, and isn't already mid-grab. Movies +/// have no per-item "monitored" row the way episodes do (that lives on +/// `media_item` itself), and no upgrade-after-file path — once a file +/// exists this permanently returns false, same as the TV path's `has_file` +/// check. +fn movie_needs_grab(conn: &Connection, media_item_id: i64) -> Result { + let monitored: i64 = conn.query_row( + "SELECT monitored FROM media_item WHERE id = ?1", + params![media_item_id], + |row| row.get(0), + )?; + if monitored == 0 { + return Ok(false); + } + let has_file: i64 = conn.query_row( + "SELECT count(*) FROM episode_file WHERE media_item_id = ?1 AND episode_id IS NULL", + params![media_item_id], + |row| row.get(0), + )?; + if has_file > 0 { + return Ok(false); + } + let in_flight: i64 = conn.query_row( + "SELECT count(*) FROM release WHERE media_item_id = ?1 AND episode_id IS NULL + AND status IN ('grabbed','downloading')", + params![media_item_id], + |row| row.get(0), + )?; + Ok(in_flight == 0) +} + +/// Upgrade-search counterpart to `movie_needs_grab`: same monitored/ +/// not-in-flight checks, but deliberately has no `has_file` gate — the +/// entire point of an upgrade check is to run against a movie that already +/// has a file, so this is eligibility for a *re-grab*, not a first grab. +fn movie_eligible_for_upgrade(conn: &Connection, media_item_id: i64) -> Result { + let monitored: i64 = conn.query_row( + "SELECT monitored FROM media_item WHERE id = ?1", + params![media_item_id], + |row| row.get(0), + )?; + if monitored == 0 { + return Ok(false); + } + let in_flight: i64 = conn.query_row( + "SELECT count(*) FROM release WHERE media_item_id = ?1 AND episode_id IS NULL + AND status IN ('grabbed','downloading')", + params![media_item_id], + |row| row.get(0), + )?; + Ok(in_flight == 0) +} + +fn is_anime(conn: &Connection, tvdb_id: i64) -> Result { + let count: i64 = conn.query_row( + "SELECT count(*) FROM anime_mapping WHERE tvdb_id = ?1", + params![tvdb_id], + |row| row.get(0), + )?; + Ok(count > 0) +} + +/// Resolves a parsed release to a (season, episode) pair: directly if the +/// title carried explicit season/episode numbers, or via the anime absolute- +/// numbering map when it only carried an absolute episode number. `pub(crate)` +/// so `library_scan` can reuse it for fansub-named files already on disk +/// (e.g. "[SubsPlease] Show - 05.mkv", absolute numbering only, no season +/// marker at all) instead of duplicating the same anime_map fallback. +pub(crate) fn resolve_episode( + conn: &Connection, + tvdb_id: Option, + parsed: &ParsedRelease, +) -> Result> { + if let (Some(season), Some(episode)) = (parsed.season, parsed.episode) { + return Ok(Some((season, episode))); + } + if let (Some(absolute), Some(tvdb_id)) = (parsed.absolute_episode, tvdb_id) { + return anime_map::resolve_absolute_episode(conn, tvdb_id, absolute); + } + Ok(None) +} + +/// Looks up an episode by identity alone (no monitored/has_file +/// eligibility filtering) — used by the season-pack importer, which is +/// matching "what episode is this actual file" rather than "is this worth +/// grabbing." `pub(crate)` so `importer` can reuse it instead of +/// duplicating the same query. +pub(crate) fn find_episode_id( + conn: &Connection, + media_item_id: i64, + season: u32, + episode: u32, +) -> Result> { + conn.query_row( + "SELECT id FROM episode WHERE media_item_id = ?1 AND season_number = ?2 AND episode_number = ?3", + params![media_item_id, season, episode], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) +} + +fn find_monitored_missing_episode( + conn: &Connection, + media_item_id: i64, + season: u32, + episode: u32, +) -> Result> { + conn.query_row( + "SELECT id FROM episode WHERE media_item_id = ?1 AND season_number = ?2 + AND episode_number = ?3 AND monitored = 1 AND has_file = 0", + params![media_item_id, season, episode], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) +} + +/// Upgrade-search counterpart to `find_monitored_missing_episode`: same +/// monitored check, but deliberately has no `has_file` gate — see +/// `movie_eligible_for_upgrade`'s doc comment for why. +fn find_monitored_episode( + conn: &Connection, + media_item_id: i64, + season: u32, + episode: u32, +) -> Result> { + conn.query_row( + "SELECT id FROM episode WHERE media_item_id = ?1 AND season_number = ?2 + AND episode_number = ?3 AND monitored = 1", + params![media_item_id, season, episode], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) +} + +/// Season-pack eligibility check: is there anything in this season actually +/// worth grabbing the whole pack for? Without this, a season already fully +/// owned via individual episode grabs would still pull down a full +/// duplicate pack every time one showed up in a feed/search, since (unlike +/// the single-episode path) nothing else here naturally excludes an +/// already-complete season. +fn count_monitored_missing_episodes_in_season( + conn: &Connection, + media_item_id: i64, + season: u32, +) -> Result { + conn.query_row( + "SELECT count(*) FROM episode WHERE media_item_id = ?1 AND season_number = ?2 + AND monitored = 1 AND has_file = 0", + params![media_item_id, season], + |row| row.get(0), + ) + .map_err(Into::into) +} + +/// Rough heuristic for whether a release's title indicates English audio, +/// used only to gate obviously-wrong candidates pre-download — actual +/// audio-track composition is only known for certain after download, which +/// is where the real fix (remux, or reject) happens (Phase 8). +fn infer_has_english_audio(title_raw: &str) -> bool { + const NON_ENGLISH_ONLY_MARKERS: &[&str] = &["VOSTFR", "VOSTA", "ITA ", "LATINO"]; + let upper = title_raw.to_uppercase(); + !NON_ENGLISH_ONLY_MARKERS.iter().any(|m| upper.contains(m)) +} + +fn best_existing_score(conn: &Connection, episode_id: i64) -> Result> { + conn.query_row( + "SELECT MAX(score) FROM release WHERE episode_id = ?1 AND status IN ('grabbed','imported','upgraded')", + params![episode_id], + |row| row.get::<_, Option>(0), + ) + .map_err(Into::into) +} + +fn best_existing_movie_score(conn: &Connection, media_item_id: i64) -> Result> { + conn.query_row( + "SELECT MAX(score) FROM release WHERE media_item_id = ?1 AND episode_id IS NULL + AND status IN ('grabbed','imported','upgraded')", + params![media_item_id], + |row| row.get::<_, Option>(0), + ) + .map_err(Into::into) +} + +/// Season-pack counterpart to `best_existing_movie_score` — scoped by +/// `season_number` (not just `episode_id IS NULL`, which a movie's own +/// check relies on) because one TV media_item can have several *different* +/// season packs' grab history, which `episode_id IS NULL` alone can't tell +/// apart. +fn best_existing_season_pack_score( + conn: &Connection, + media_item_id: i64, + season: u32, +) -> Result> { + conn.query_row( + "SELECT MAX(score) FROM release WHERE media_item_id = ?1 AND season_number = ?2 + AND status IN ('grabbed','imported','upgraded')", + params![media_item_id, season], + |row| row.get::<_, Option>(0), + ) + .map_err(Into::into) +} + +/// Whether a newly-scored candidate should be grabbed: always if nothing's +/// been grabbed for this episode yet; otherwise only if it's a repack/proper +/// (fixes a known-bad prior encode) or outscores the current best by more +/// than `min_gain`. `min_gain` is 0.0 for a normal missing-content grab +/// (any strict improvement counts) and a configured positive threshold for +/// an upgrade-search grab (see `SearchTarget::upgrade_min_gain`), so a file +/// already on disk isn't replaced over and over for score deltas too small +/// to matter. +fn should_grab(new_score: f32, is_repack: bool, existing_best: Option, min_gain: f32) -> bool { + match existing_best { + None => true, + Some(existing) => is_repack || new_score > existing + min_gain, + } +} + +/// `category` here is qBittorrent's category, not `source`'s `kind` — kept +/// as its own parameter (rather than derived from `qbit_category` at the +/// call site) so `torrent_fetch.category` always reflects what the torrent +/// actually got added under, even if that config value changes later. +#[allow(clippy::too_many_arguments)] +fn record_grab( + conn: &Connection, + media_item_id: i64, + episode_id: Option, + season_number: Option, + source_id: i64, + raw_title: &str, + guid: &str, + score: f32, + size_bytes: Option, + category: &str, + torrent_hash: Option<&str>, + status: &str, +) -> Result<()> { + conn.execute( + "INSERT INTO release (media_item_id, episode_id, season_number, raw_title, source_id, guid, score, torrent_hash, status, grabbed_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'))", + params![media_item_id, episode_id, season_number, raw_title, source_id, guid, score, torrent_hash, status], + )?; + crate::db::record_event( + conn, + media_item_id, + episode_id, + status, + &format!("score={score:.1} title={raw_title:?}"), + )?; + // Permanent audit trail, independent of whatever later happens to the + // `release` row above (status changes, or the row/its media_item being + // deleted) — see `torrent_fetch`'s own doc comment in db.rs. + crate::db::record_torrent_fetch( + conn, + torrent_hash, + raw_title, + size_bytes, + category, + source_id, + media_item_id, + episode_id, + status, + )?; + Ok(()) +} + +fn is_seen(conn: &Connection, source_id: i64, guid: &str) -> Result { + let count: i64 = conn.query_row( + "SELECT count(*) FROM seen_guid WHERE source_id = ?1 AND guid = ?2", + params![source_id, guid], + |row| row.get(0), + )?; + Ok(count > 0) +} + +fn mark_seen(conn: &Connection, source_id: i64, guid: &str) -> Result<()> { + conn.execute( + "INSERT OR IGNORE INTO seen_guid (source_id, guid, seen_at) VALUES (?1, ?2, datetime('now'))", + params![source_id, guid], + )?; + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn process_item( + conn: &Connection, + item: &RawReleaseItem, + matcher: &mut TitleMatcher, + qbit: &QbitClient, + qbit_category: &str, + source_id: i64, + better_resolution_available: bool, + upgrade_min_gain: Option, +) -> Result { + if looks_like_non_video_format(&item.title) { + return Ok(ProcessOutcome::NonVideoFormat); + } + + let parsed = parser::parse(&item.title); + + let candidate = match matcher.match_title(conn, &parsed.title_normalized)? { + MatchOutcome::Auto(c) => c, + MatchOutcome::NeedsReview(c) => { + matcher::queue_for_review(conn, &item.title, &c, Some(&item.link), Some(source_id))?; + return Ok(ProcessOutcome::QueuedForReview); + } + MatchOutcome::NoMatch => return Ok(ProcessOutcome::NoMatch), + }; + + let media_item = get_media_item(conn, candidate.media_item_id)?; + + let mut episode_id: Option = None; + let mut season_pack_number: Option = None; + + if media_item.kind == "movie" { + // A release carrying a season/episode/absolute-episode marker + // title-matched a movie by name alone — it's actually an episode + // of some same-named show, not this movie. + if looks_like_episode(&parsed) { + return Ok(ProcessOutcome::NoMatch); + } + if movie_year_mismatch(parsed.year, media_item.year) { + return Ok(ProcessOutcome::YearMismatch); + } + let eligible = match upgrade_min_gain { + Some(_) => movie_eligible_for_upgrade(conn, media_item.id)?, + None => movie_needs_grab(conn, media_item.id)?, + }; + if !eligible { + return Ok(ProcessOutcome::NotMonitoredOrAlreadyHave); + } + } else if looks_like_season_pack(&parsed) { + let Some(season) = parsed.season else { + return Ok(ProcessOutcome::CouldNotResolveEpisode); + }; + if count_monitored_missing_episodes_in_season(conn, media_item.id, season)? == 0 { + return Ok(ProcessOutcome::NotMonitoredOrAlreadyHave); + } + season_pack_number = Some(season); + } else { + let Some((season, episode)) = resolve_episode(conn, media_item.tvdb_id, &parsed)? else { + return Ok(ProcessOutcome::CouldNotResolveEpisode); + }; + let eid_opt = match upgrade_min_gain { + Some(_) => find_monitored_episode(conn, media_item.id, season, episode)?, + None => find_monitored_missing_episode(conn, media_item.id, season, episode)?, + }; + let Some(eid) = eid_opt else { + return Ok(ProcessOutcome::NotMonitoredOrAlreadyHave); + }; + episode_id = Some(eid); + } + + let anime = match media_item.tvdb_id { + Some(id) => is_anime(conn, id)?, + None => false, + }; + let profile_kind = if media_item.kind == "movie" { + ProfileKind::Movie + } else { + ProfileKind::Tv + }; + let profile = load_quality_profile(conn, media_item.quality_profile_id, profile_kind)?; + + let gate_ctx = GateContext { + seeders: item.seeders, + size_bytes: item.size_bytes, + runtime_minutes: None, + has_english_audio: infer_has_english_audio(&item.title), + is_anime: anime, + better_resolution_available, + is_season_pack: season_pack_number.is_some(), + }; + + if let GateResult::Reject(reason) = scoring::evaluate_gates(&parsed, &gate_ctx, &profile) { + return Ok(ProcessOutcome::GateRejected(reason)); + } + + let release_score = scoring::score(&parsed, item.seeders.unwrap_or(0), false, &profile); + let existing_best = match (episode_id, season_pack_number) { + (Some(eid), _) => best_existing_score(conn, eid)?, + (None, Some(season)) => best_existing_season_pack_score(conn, media_item.id, season)?, + (None, None) => best_existing_movie_score(conn, media_item.id)?, + }; + + if !should_grab( + release_score, + parsed.is_repack, + existing_best, + upgrade_min_gain.unwrap_or(0.0), + ) { + return Ok(ProcessOutcome::NotBetterThanExisting); + } + + let torrent_hash = match grab_and_capture_hash(qbit, &item.link, qbit_category).await { + Ok(hash) => hash, + Err(e) if e.downcast_ref::().is_some() => { + return Ok(ProcessOutcome::MagnetRejected); + } + Err(e) => return Err(e), + }; + + let Some(torrent_hash) = torrent_hash else { + // The add almost certainly succeeded on qBittorrent's side, but we + // couldn't correlate which torrent it became — recording this as + // `grabbed` with a NULL hash would strand it forever (see + // `ProcessOutcome::HashCaptureFailed`'s doc comment), so it's + // recorded as `failed` instead, freeing the episode/movie for a + // future search to try again with a different candidate. + record_grab( + conn, + media_item.id, + episode_id, + season_pack_number, + source_id, + &item.title, + &item.guid, + release_score, + item.size_bytes, + qbit_category, + None, + "failed", + )?; + return Ok(ProcessOutcome::HashCaptureFailed); + }; + + record_grab( + conn, + media_item.id, + episode_id, + season_pack_number, + source_id, + &item.title, + &item.guid, + release_score, + item.size_bytes, + qbit_category, + Some(&torrent_hash), + "grabbed", + )?; + + Ok(ProcessOutcome::Grabbed { + media_item_id: media_item.id, + episode_id, + season_number: season_pack_number, + score: release_score, + }) +} + +/// The number of `torrents/info` polls to attempt when a link gives us no +/// hash up front (nyaa's `.torrent`-URL links) before giving up. qBittorrent +/// must first fetch the `.torrent` file itself before the torrent exists in +/// its own list, so the old zero-delay before/after snapshot routinely +/// missed it entirely (verified live: 23/23 NULL hashes in one batch). +const HASH_POLL_ATTEMPTS: u32 = 15; +const HASH_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2); + +/// Finds a torrent's hash so a completed download can later be correlated +/// back to its `release` row. +/// +/// `link` may be a detail-page URL rather than a directly-grabbable magnet +/// (1337x search results only carry the former) — resolved here, right +/// before the grab, so this stays a no-op for sources that are already +/// directly grabbable (nyaa) and only costs a request for the one +/// candidate actually being downloaded. +/// +/// Once resolved, a magnet link already carries its own infohash +/// (`urn:btih:...`) — extracted directly, no need to ask qBittorrent at all. +/// Only nyaa's `.torrent`-URL links (not magnets) fall through to polling +/// `torrents/info` for a newly-registered hash, which is both slower and +/// inherently racy against any *other* concurrent add — `grab_lock` +/// serializes the whole add-and-correlate sequence across every caller +/// (the background loop and the API's review-approve handler both add +/// against the same category and can otherwise interleave, previously +/// letting one grab record a hash that actually belonged to a different, +/// concurrently-added torrent). +async fn grab_and_capture_hash( + qbit: &QbitClient, + link: &str, + category: &str, +) -> Result> { + let resolved; + let link = if sources::scrape::needs_resolution(link) { + // No total timeout is reqwest's default — this runs inside the + // background loop while it holds the DB mutex, so a stalled + // connection would hang the whole daemon. + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build()?; + resolved = sources::scrape::resolve_magnet(&client, link).await?; + &resolved + } else { + link + }; + + let _grab_guard = qbit.lock_for_grab().await; + + if let Some(hash) = crate::qbit::extract_btih(link) { + qbit.add_magnet(link, category).await?; + return Ok(Some(hash)); + } + + let before: std::collections::HashSet = qbit + .list_torrents(Some(category)) + .await? + .into_iter() + .map(|t| t.hash) + .collect(); + + qbit.add_magnet(link, category).await?; + + for attempt in 0..HASH_POLL_ATTEMPTS { + if attempt > 0 { + tokio::time::sleep(HASH_POLL_INTERVAL).await; + } + let after = qbit.list_torrents(Some(category)).await?; + let mut new_hashes = after.into_iter().filter(|t| !before.contains(&t.hash)); + let Some(hash) = new_hashes.next() else { + continue; + }; + if new_hashes.next().is_some() { + tracing::warn!( + "multiple new torrents appeared between snapshots; hash correlation may be ambiguous" + ); + } + return Ok(Some(hash.hash)); + } + tracing::warn!( + link, + "gave up waiting for the added torrent to register with qbittorrent" + ); + Ok(None) +} + +#[derive(Debug, Default)] +pub struct GrabCycleStats { + pub items_seen: usize, + pub new_items: usize, + pub grabbed: usize, + pub errors: usize, + pub queued_for_review: usize, +} + +pub async fn run_grab_cycle( + conn: &Connection, + source: &dyn ReleaseSource, + source_id: i64, + matcher: &mut TitleMatcher, + qbit: &QbitClient, + qbit_category: &str, +) -> Result { + let items = source.fetch(None).await?; + let mut stats = GrabCycleStats { + items_seen: items.len(), + ..Default::default() + }; + + for item in items { + if is_seen(conn, source_id, &item.guid)? { + continue; + } + + // Marked seen only on a deterministic verdict (matched-and-grabbed, + // no-match, needs-review, etc.) — never on `Err`, which is usually + // transient (qbit momentarily unreachable, a stalled request). Guids + // aren't re-emitted by a feed indefinitely, so leaving a failed item + // unmarked just lets the next cycle retry it while it's still in + // the source's window, rather than permanently losing it to a blip. + // `false`: the RSS feed-watch path sees one item at a time as it + // streams in, with no batch of alternatives to compare against — + // the low-resolution-alternative-exists gate only makes sense on + // the search-driven path below, which fetches a whole candidate + // list per target up front. + match process_item( + conn, + &item, + matcher, + qbit, + qbit_category, + source_id, + false, + None, + ) + .await + { + Ok(outcome) => { + mark_seen(conn, source_id, &item.guid)?; + stats.new_items += 1; + match outcome { + ProcessOutcome::Grabbed { .. } => stats.grabbed += 1, + ProcessOutcome::QueuedForReview => stats.queued_for_review += 1, + _ => {} + } + } + Err(e) => { + tracing::warn!(error = %e, title = %item.title, "failed to process release item"); + stats.errors += 1; + } + } + } + + Ok(stats) +} + +// --- Search-driven acquisition (1337x for general TV/movies, nyaa search +// for anime movies) --- +// +// Unlike the feed-based path above, there's no natural stream of "new" +// items to dedup against — the recurring cost here is the *search itself*, +// repeated against the same monitored-but-missing catalog. `search_state` +// tracks per-item search history so the same item isn't re-searched every +// single cycle; cadence backs off exponentially (6h, 12h, 24h, 48h, 96h, +// capped at a week) the more times it's been searched without success. + +/// Other general-content sources considered and rejected (live-tested +/// 2026-07-12, not just assumed) before landing on TPB as primary: +/// - **YTS** (`yts.mx`): DNS doesn't resolve at all. Every known mirror +/// (`yts.am`, `yts.ag`, `yts.lt`, `yts.pe`) either 301s in a loop or drops +/// the query and lands on a bare homepage. The whole mirror network looks +/// dead, not just one domain — re-check before assuming a fix is quick. +/// - **EZTV** (`eztv.re`): redirects to `eztvx.to`, which fails to connect +/// outright (TLS/connection error, not a slow response). Also +/// Cloudflare-fronted, so even if connectivity is restored it carries the +/// same risk profile 1337x does. +/// If revisiting either, re-verify connectivity first — this isn't a +/// permanent architectural decision, just what was true when checked. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum SearchRoute { + /// Primary general-content (movies + non-anime TV) route — a JSON API, + /// ranked by relevance rather than pure seeder count. + Tpb, + /// Kept wired in as a fallback for when TPB's own fetch fails outright + /// (not just "no relevant results") — the mirror-rotation/cooldown + /// machinery already built for it is real resilience worth keeping, + /// just no longer the first choice given TPB's better precision. + X1337, + NyaaSearch, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SearchTarget { + media_item_id: i64, + episode_id: Option, + /// The target episode's season — `None` for a movie target. Lets + /// `execute_search_targets` recognize a season-pack grab as having + /// satisfied this target even though it has no single `episode_id` + /// of its own. + season_number: Option, + query: String, + route: SearchRoute, + /// Significant (len >= 4, alphanumeric) lowercased words from the + /// show/movie's own title — used to pre-filter obviously-irrelevant + /// search results before they ever reach the title matcher. 1337x's + /// search does loose keyword matching, not phrase matching: a query + /// for "Modern Family S00E01" surfaces "Family Guy" and "The Addams + /// Family" at the top by seeder count, sharing only the generic word + /// "family" — without this filter those get fed straight into the + /// embedding matcher and, worse, can occasionally out-similarity a + /// genuine but lower-quality result. + title_words: Vec, + /// `None` for a normal missing-content target. `Some(gain)` marks this + /// as an upgrade-search target for an episode/movie that already has a + /// file — `process_item` then looks the item up regardless of + /// `has_file`/existing-file state, and a new candidate must beat the + /// current best score by at least `gain` (repacks/propers still always + /// supersede) rather than merely being strictly better. + upgrade_min_gain: Option, +} + +fn significant_words(title: &str) -> Vec { + title + .split_whitespace() + .map(|w| w.to_lowercase()) + .filter(|w| w.len() >= 4 && w.chars().all(|c| c.is_alphanumeric())) + .collect() +} + +/// True unless the target has at least one significant word (nothing to +/// filter on — a one-or-two-short-word title lets everything through) and +/// the candidate title is missing *any* of them. Requiring every word +/// rather than a fraction is deliberately strict: the false positives seen +/// in practice ("The First Purge" for a "...First Marriage" query) share +/// exactly one word with the target, and a fractional threshold wouldn't +/// exclude them for short titles. +fn passes_relevance_filter(target_words: &[String], candidate_title: &str) -> bool { + if target_words.is_empty() { + return true; + } + let lower = candidate_title.to_lowercase(); + target_words.iter().all(|w| lower.contains(w.as_str())) +} + +/// 1337x's search chokes on punctuation (colons, apostrophes) — replace +/// anything that isn't alphanumeric/whitespace with a space and collapse. +fn sanitize_query_text(s: &str) -> String { + let cleaned: String = s + .chars() + .map(|c| { + if c.is_alphanumeric() || c.is_whitespace() { + c + } else { + ' ' + } + }) + .collect(); + parser::WS_RE.replace_all(cleaned.trim(), " ").to_string() +} + +/// TPB's search (apibay) does literal multi-term matching — appending an +/// "SxxEyy" token alongside a multi-word title routinely returns zero +/// results even when genuine episodes for that exact season/episode exist +/// and would surface from a plain title search (verified live: "Show Title +/// S01E01" → zero results, "Show Title" alone → real S01E13/E14/E15 results +/// a few rows down). So TPB gets a title-only query; `process_item` already +/// parses season/episode back out of each *result's* own title via +/// `parser::parse`, so nothing about episode identification depends on the +/// query carrying it. 1337x (still reachable as TPB's fetch-failure +/// fallback) keeps the season/episode suffix, since it doesn't share this +/// failure mode and the extra scoping helps there. +fn build_tv_query(title: &str, season: i64, episode: i64, route: SearchRoute) -> String { + match route { + SearchRoute::Tpb => sanitize_query_text(title), + SearchRoute::X1337 | SearchRoute::NyaaSearch => { + format!("{} S{season:02}E{episode:02}", sanitize_query_text(title)) + } + } +} + +fn build_movie_query(title: &str, year: Option) -> String { + match year { + Some(y) => format!("{} {y}", sanitize_query_text(title)), + None => sanitize_query_text(title), + } +} + +/// SQL fragment (embedded inline, not a bind parameter — SQLite has no +/// syntax for parameterizing an expression) implementing the cadence +/// formula: due immediately if never searched, otherwise `6h * 2^(count-1)` +/// after the last attempt, capped at a week. The exponent itself is +/// separately clamped to 10 (`6 * 2^10` hours is already ~4.7 years, far +/// past the 168-hour outer cap) — SQLite integers are 64-bit signed, so an +/// uncapped `search_count` eventually overflows `1 << (search_count - 1)` +/// into a negative number (verified in SQLite directly: happens at +/// search_count=64) and `min(168, negative)` then picks the negative value, +/// making `datetime(..., '+' || negative || ' hours')` land in the past — +/// so a chronically failing target would silently flip from backing off +/// weekly to being searched *every single cycle* instead, right when it +/// should be backing off the most. +const DUE_CLAUSE: &str = "(last_searched_at IS NULL \ + OR datetime(last_searched_at, '+' || min(168, 6 * (1 << min(search_count - 1, 10))) || ' hours') <= datetime('now'))"; + +/// Same exponential-backoff shape as `DUE_CLAUSE`, but with a much longer +/// base (24h vs. 6h) and outer cap (30 days vs. 1 week) — there's no urgency +/// to re-checking something that's already satisfied by a file on disk, so +/// this cadence is deliberately slower than the missing-content one. +const UPGRADE_DUE_CLAUSE: &str = "(last_checked_at IS NULL \ + OR datetime(last_checked_at, '+' || min(720, 24 * (1 << min(check_count - 1, 10))) || ' hours') <= datetime('now'))"; + +/// Enumerates up to `budget` monitored-but-missing items due for a search +/// attempt, across both non-anime TV episodes and movies. Anime TV isn't +/// included — it's already covered by the proven nyaa RSS feed watch, which +/// carries no request-budget risk the way repeated searches do. +/// +/// Priority when there are more due candidates than budget: never-searched +/// items first (so a newly added show/movie gets immediate coverage instead +/// of starving behind a backlog of retries), then most-overdue-searched +/// first. `Option` sorts `None` before `Some` in Rust, which +/// matches SQLite's own default NULLS-FIRST behavior for `ASC` — no manual +/// timestamp parsing needed to get this ordering right. +fn enumerate_search_targets(conn: &Connection, budget: usize) -> Result> { + let mut candidates: Vec<(SearchTarget, Option, String)> = Vec::new(); + + let mut stmt = conn.prepare(&format!( + "SELECT e.id, e.media_item_id, m.title, e.season_number, e.episode_number, e.air_date, + ss.last_searched_at + FROM episode e + JOIN media_item m ON m.id = e.media_item_id + LEFT JOIN search_state ss ON ss.episode_id = e.id + WHERE m.monitored = 1 AND m.kind = 'series' + AND e.monitored = 1 AND e.has_file = 0 + AND e.air_date IS NOT NULL AND e.air_date <= date('now') + AND (m.tvdb_id IS NULL OR m.tvdb_id NOT IN + (SELECT tvdb_id FROM anime_mapping WHERE tvdb_id IS NOT NULL)) + AND NOT EXISTS (SELECT 1 FROM release r WHERE r.episode_id = e.id + AND r.status IN ('grabbed','downloading')) + AND (ss.last_searched_at IS NULL OR {})", + DUE_CLAUSE + .replace("last_searched_at", "ss.last_searched_at") + .replace("search_count", "ss.search_count") + ))?; + struct EpisodeCandidateRow { + episode_id: i64, + media_item_id: i64, + title: String, + season: i64, + episode: i64, + air_date: String, + last_searched_at: Option, + } + let rows: Vec = stmt + .query_map([], |row| { + Ok(EpisodeCandidateRow { + episode_id: row.get(0)?, + media_item_id: row.get(1)?, + title: row.get(2)?, + season: row.get(3)?, + episode: row.get(4)?, + air_date: row.get(5)?, + last_searched_at: row.get(6)?, + }) + })? + .collect::>()?; + for EpisodeCandidateRow { + episode_id, + media_item_id, + title, + season, + episode, + air_date, + last_searched_at, + } in rows + { + candidates.push(( + SearchTarget { + media_item_id, + episode_id: Some(episode_id), + season_number: Some(season as u32), + query: build_tv_query(&title, season, episode, SearchRoute::Tpb), + title_words: significant_words(&title), + route: SearchRoute::Tpb, + upgrade_min_gain: None, + }, + last_searched_at, + air_date, + )); + } + + let mut stmt = conn.prepare(&format!( + "SELECT m.id, m.title, m.year, + (m.anidb_id IS NOT NULL + OR m.tmdb_id IN (SELECT tmdb_id FROM anime_mapping WHERE tmdb_id IS NOT NULL) + OR m.tmdb_id IN (SELECT tmdb_id FROM anime_tmdb_movie)) AS is_anime_movie, + ss.last_searched_at + FROM media_item m + LEFT JOIN search_state ss ON ss.media_item_id = m.id AND ss.episode_id IS NULL + WHERE m.kind = 'movie' AND m.monitored = 1 + AND NOT EXISTS (SELECT 1 FROM episode_file f + WHERE f.media_item_id = m.id AND f.episode_id IS NULL) + AND NOT EXISTS (SELECT 1 FROM release r WHERE r.media_item_id = m.id + AND r.episode_id IS NULL AND r.status IN ('grabbed','downloading')) + AND (ss.last_searched_at IS NULL OR {})", + DUE_CLAUSE + .replace("last_searched_at", "ss.last_searched_at") + .replace("search_count", "ss.search_count") + ))?; + struct MovieCandidateRow { + media_item_id: i64, + title: String, + year: Option, + is_anime_movie: i64, + last_searched_at: Option, + } + let rows: Vec = stmt + .query_map([], |row| { + Ok(MovieCandidateRow { + media_item_id: row.get(0)?, + title: row.get(1)?, + year: row.get(2)?, + is_anime_movie: row.get(3)?, + last_searched_at: row.get(4)?, + }) + })? + .collect::>()?; + for MovieCandidateRow { + media_item_id, + title, + year, + is_anime_movie, + last_searched_at, + } in rows + { + let route = if is_anime_movie != 0 { + SearchRoute::NyaaSearch + } else { + SearchRoute::Tpb + }; + candidates.push(( + SearchTarget { + media_item_id, + episode_id: None, + season_number: None, + query: build_movie_query(&title, year), + title_words: significant_words(&title), + route, + upgrade_min_gain: None, + }, + last_searched_at, + format!("{media_item_id:020}"), + )); + } + + candidates.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| b.2.cmp(&a.2))); + candidates.truncate(budget); + Ok(candidates.into_iter().map(|(t, _, _)| t).collect()) +} + +/// Upgrade-search counterpart to `enumerate_search_targets`: the mirror +/// image query — `has_file = 1`/`EXISTS episode_file` instead of `= 0`/ +/// `NOT EXISTS`, joined against `upgrade_state` instead of `search_state` — +/// enumerating up to `budget` monitored-and-already-owned items due for an +/// upgrade check. Anime TV is excluded for the same reason as the +/// missing-content path: it's covered by the nyaa RSS feed watch instead. +/// Every target gets `upgrade_min_gain: Some(min_gain)`, which is what +/// `process_item` uses both to bypass the normal `has_file` eligibility +/// gates and to require a new candidate to beat the current file's score by +/// more than a marginal amount (see `should_grab`). +/// SQL fragment computing whether a file has any of the ground-truth +/// `media_file_probe` problem signals worth prioritizing an upgrade check +/// for — under-quality (probe-verified, not just what the release title +/// claimed), missing subtitles, a non-English default audio track (the +/// at-import remux fix should normally catch this, but a pre-existing +/// library file might predate it), or a probe/decode failure. `p` must be +/// the table's alias in the query this is embedded in. A file with no probe +/// row yet (still queued for the incremental probe sweep) reads as +/// not-flagged rather than flagged — there's nothing to act on until it's +/// actually been probed. +const NEEDS_ATTENTION_CLAUSE: &str = "(COALESCE(p.flag_under_quality, 0) = 1 \ + OR COALESCE(p.flag_no_subtitles, 0) = 1 \ + OR COALESCE(p.flag_non_english_default_audio, 0) = 1 \ + OR COALESCE(p.corruption_status, '') IN ('probe_failed', 'decode_failed'))"; + +/// Upgrade-search counterpart to `enumerate_search_targets`: the mirror +/// image query — `has_file = 1`/`EXISTS episode_file` instead of `= 0`/ +/// `NOT EXISTS`, joined against `upgrade_state` instead of `search_state` — +/// enumerating up to `budget` monitored-and-already-owned items due for an +/// upgrade check. Anime TV is excluded for the same reason as the +/// missing-content path: it's covered by the nyaa RSS feed watch instead. +/// Every target gets `upgrade_min_gain: Some(min_gain)`, which is what +/// `process_item` uses both to bypass the normal `has_file` eligibility +/// gates and to require a new candidate to beat the current file's score by +/// more than a marginal amount (see `should_grab`). +/// +/// Ordering prioritizes `media_file_probe`-flagged files +/// (`NEEDS_ATTENTION_CLAUSE`) ahead of everything else, so a limited budget +/// lands on files with a real, ground-truth-verified problem before it's +/// spent on routine re-checks of files nothing is actually wrong with. Due- +/// ness (never-checked first, then most-overdue) is still the tiebreaker +/// within each of those two priority tiers. +fn enumerate_upgrade_targets( + conn: &Connection, + budget: usize, + min_gain: f32, +) -> Result> { + let mut candidates: Vec<(SearchTarget, bool, Option, String)> = Vec::new(); + + let mut stmt = conn.prepare(&format!( + "SELECT e.id, e.media_item_id, m.title, e.season_number, e.episode_number, e.air_date, + us.last_checked_at, {NEEDS_ATTENTION_CLAUSE} AS needs_attention + FROM episode e + JOIN media_item m ON m.id = e.media_item_id + LEFT JOIN upgrade_state us ON us.episode_id = e.id + LEFT JOIN episode_file ef ON ef.episode_id = e.id + LEFT JOIN media_file_probe p ON p.episode_file_id = ef.id + WHERE m.monitored = 1 AND m.kind = 'series' + AND e.monitored = 1 AND e.has_file = 1 + AND (m.tvdb_id IS NULL OR m.tvdb_id NOT IN + (SELECT tvdb_id FROM anime_mapping WHERE tvdb_id IS NOT NULL)) + AND NOT EXISTS (SELECT 1 FROM release r WHERE r.episode_id = e.id + AND r.status IN ('grabbed','downloading')) + AND (us.last_checked_at IS NULL OR {})", + UPGRADE_DUE_CLAUSE + .replace("last_checked_at", "us.last_checked_at") + .replace("check_count", "us.check_count") + ))?; + struct EpisodeCandidateRow { + episode_id: i64, + media_item_id: i64, + title: String, + season: i64, + episode: i64, + last_checked_at: Option, + needs_attention: bool, + } + let rows: Vec = stmt + .query_map([], |row| { + Ok(EpisodeCandidateRow { + episode_id: row.get(0)?, + media_item_id: row.get(1)?, + title: row.get(2)?, + season: row.get(3)?, + episode: row.get(4)?, + last_checked_at: row.get(6)?, + needs_attention: row.get(7)?, + }) + })? + .collect::>()?; + for EpisodeCandidateRow { + episode_id, + media_item_id, + title, + season, + episode, + last_checked_at, + needs_attention, + } in rows + { + candidates.push(( + SearchTarget { + media_item_id, + episode_id: Some(episode_id), + season_number: Some(season as u32), + query: build_tv_query(&title, season, episode, SearchRoute::Tpb), + title_words: significant_words(&title), + route: SearchRoute::Tpb, + upgrade_min_gain: Some(min_gain), + }, + needs_attention, + last_checked_at, + format!("{episode_id:020}"), + )); + } + + let mut stmt = conn.prepare(&format!( + "SELECT m.id, m.title, m.year, + (m.anidb_id IS NOT NULL + OR m.tmdb_id IN (SELECT tmdb_id FROM anime_mapping WHERE tmdb_id IS NOT NULL) + OR m.tmdb_id IN (SELECT tmdb_id FROM anime_tmdb_movie)) AS is_anime_movie, + us.last_checked_at, {NEEDS_ATTENTION_CLAUSE} AS needs_attention + FROM media_item m + LEFT JOIN upgrade_state us ON us.media_item_id = m.id AND us.episode_id IS NULL + LEFT JOIN episode_file ef ON ef.media_item_id = m.id AND ef.episode_id IS NULL + LEFT JOIN media_file_probe p ON p.episode_file_id = ef.id + WHERE m.kind = 'movie' AND m.monitored = 1 + AND EXISTS (SELECT 1 FROM episode_file f + WHERE f.media_item_id = m.id AND f.episode_id IS NULL) + AND NOT EXISTS (SELECT 1 FROM release r WHERE r.media_item_id = m.id + AND r.episode_id IS NULL AND r.status IN ('grabbed','downloading')) + AND (us.last_checked_at IS NULL OR {})", + UPGRADE_DUE_CLAUSE + .replace("last_checked_at", "us.last_checked_at") + .replace("check_count", "us.check_count") + ))?; + struct MovieCandidateRow { + media_item_id: i64, + title: String, + year: Option, + is_anime_movie: i64, + last_checked_at: Option, + needs_attention: bool, + } + let rows: Vec = stmt + .query_map([], |row| { + Ok(MovieCandidateRow { + media_item_id: row.get(0)?, + title: row.get(1)?, + year: row.get(2)?, + is_anime_movie: row.get(3)?, + last_checked_at: row.get(4)?, + needs_attention: row.get(5)?, + }) + })? + .collect::>()?; + for MovieCandidateRow { + media_item_id, + title, + year, + is_anime_movie, + last_checked_at, + needs_attention, + } in rows + { + let route = if is_anime_movie != 0 { + SearchRoute::NyaaSearch + } else { + SearchRoute::Tpb + }; + candidates.push(( + SearchTarget { + media_item_id, + episode_id: None, + season_number: None, + query: build_movie_query(&title, year), + title_words: significant_words(&title), + route, + upgrade_min_gain: Some(min_gain), + }, + needs_attention, + last_checked_at, + format!("{media_item_id:020}"), + )); + } + + candidates.sort_by(|a, b| { + // `!needs_attention` first so `true` (flagged) sorts ahead of + // `false` — `bool`'s `Ord` puts `false < true`. + (!a.1) + .cmp(&!b.1) + .then_with(|| a.2.cmp(&b.2)) + .then_with(|| b.3.cmp(&a.3)) + }); + candidates.truncate(budget); + Ok(candidates.into_iter().map(|(t, ..)| t).collect()) +} + +fn record_search_attempt( + conn: &Connection, + media_item_id: i64, + episode_id: Option, + result: &str, +) -> Result<()> { + let existing: Option = match episode_id { + Some(eid) => conn + .query_row( + "SELECT id FROM search_state WHERE episode_id = ?1", + params![eid], + |r| r.get(0), + ) + .optional()?, + None => conn + .query_row( + "SELECT id FROM search_state WHERE media_item_id = ?1 AND episode_id IS NULL", + params![media_item_id], + |r| r.get(0), + ) + .optional()?, + }; + match existing { + Some(id) => { + conn.execute( + "UPDATE search_state SET last_searched_at = datetime('now'), + search_count = search_count + 1, last_result = ?1 WHERE id = ?2", + params![result, id], + )?; + } + None => { + conn.execute( + "INSERT INTO search_state (media_item_id, episode_id, last_searched_at, search_count, last_result) + VALUES (?1, ?2, datetime('now'), 1, ?3)", + params![media_item_id, episode_id, result], + )?; + } + } + Ok(()) +} + +/// Same upsert shape as `record_search_attempt`, against `upgrade_state` +/// instead — kept as a separate table/function pair rather than adding an +/// `is_upgrade` column to `search_state`, so the two due-ness cadences never +/// interfere with each other's clock (see `upgrade_state`'s doc comment in +/// db.rs). +fn record_upgrade_attempt( + conn: &Connection, + media_item_id: i64, + episode_id: Option, + result: &str, +) -> Result<()> { + let existing: Option = match episode_id { + Some(eid) => conn + .query_row( + "SELECT id FROM upgrade_state WHERE episode_id = ?1", + params![eid], + |r| r.get(0), + ) + .optional()?, + None => conn + .query_row( + "SELECT id FROM upgrade_state WHERE media_item_id = ?1 AND episode_id IS NULL", + params![media_item_id], + |r| r.get(0), + ) + .optional()?, + }; + match existing { + Some(id) => { + conn.execute( + "UPDATE upgrade_state SET last_checked_at = datetime('now'), + check_count = check_count + 1, last_result = ?1 WHERE id = ?2", + params![result, id], + )?; + } + None => { + conn.execute( + "INSERT INTO upgrade_state (media_item_id, episode_id, last_checked_at, check_count, last_result) + VALUES (?1, ?2, datetime('now'), 1, ?3)", + params![media_item_id, episode_id, result], + )?; + } + } + Ok(()) +} + +/// Routes to `record_search_attempt` or `record_upgrade_attempt` based on +/// which cadence `target` belongs to — lets `execute_search_targets` stay a +/// single shared implementation for both the missing-content search cycle +/// and the upgrade-search cycle. +fn record_target_attempt(conn: &Connection, target: &SearchTarget, result: &str) -> Result<()> { + match target.upgrade_min_gain { + Some(_) => record_upgrade_attempt(conn, target.media_item_id, target.episode_id, result), + None => record_search_attempt(conn, target.media_item_id, target.episode_id, result), + } +} + +#[derive(Debug, Default)] +pub struct SearchCycleStats { + pub targets: usize, + pub searched: usize, + pub grabbed: usize, + pub errors: usize, + pub queued_for_review: usize, + /// Set when a source fetch failed outright (all mirrors down/cooling, + /// or two consecutive fetch errors this cycle) — the caller uses this + /// to trigger cycle-level backoff rather than tightening the search + /// loop's own retry interval, which stays fixed. + pub source_exhausted: bool, +} + +/// Results are sorted by seeders and only the top N are evaluated — bounds +/// both the title-matcher's per-item inference cost and how much noise a +/// single broad query can dump into the review queue. +const MAX_RESULTS_PER_SEARCH: usize = 15; + +#[allow(clippy::too_many_arguments)] +pub async fn run_search_cycle( + conn: &Connection, + tpb: &sources::tpb::TpbSource, + tpb_source_id: i64, + scrape: &sources::scrape::ScrapeSource, + scrape_source_id: i64, + nyaa_search: &sources::rss::RssSource, + nyaa_source_id: i64, + matcher: &mut TitleMatcher, + qbit: &QbitClient, + qbit_category: &str, + budget: usize, +) -> Result { + let targets = enumerate_search_targets(conn, budget)?; + execute_search_targets( + conn, + &targets, + tpb, + tpb_source_id, + scrape, + scrape_source_id, + nyaa_search, + nyaa_source_id, + matcher, + qbit, + qbit_category, + ) + .await +} + +/// Upgrade-search counterpart to `run_search_cycle`: same execution engine +/// (`execute_search_targets`), fed already-owned targets instead of missing +/// ones. `min_gain` is threaded onto every target via +/// `enumerate_upgrade_targets`, which is what routes `process_item` into +/// its upgrade-eligibility path instead of the normal missing-content one. +#[allow(clippy::too_many_arguments)] +pub async fn run_upgrade_cycle( + conn: &Connection, + tpb: &sources::tpb::TpbSource, + tpb_source_id: i64, + scrape: &sources::scrape::ScrapeSource, + scrape_source_id: i64, + nyaa_search: &sources::rss::RssSource, + nyaa_source_id: i64, + matcher: &mut TitleMatcher, + qbit: &QbitClient, + qbit_category: &str, + budget: usize, + min_gain: f32, +) -> Result { + let targets = enumerate_upgrade_targets(conn, budget, min_gain)?; + execute_search_targets( + conn, + &targets, + tpb, + tpb_source_id, + scrape, + scrape_source_id, + nyaa_search, + nyaa_source_id, + matcher, + qbit, + qbit_category, + ) + .await +} + +/// Every currently-missing episode/movie for one specific `media_item`, +/// ignoring the normal due-ness cadence and global budget — for a manual, +/// explicitly-scoped one-off ("go find everything for this one show now"), +/// not part of the recurring background loop, so the usual "don't re-search +/// something too soon" throttling doesn't apply: there's nothing to +/// throttle against when the whole point is a single bounded pass over one +/// show's own backlog. +pub fn enumerate_search_targets_for_media_item( + conn: &Connection, + media_item_id: i64, +) -> Result> { + let kind: String = conn.query_row( + "SELECT kind FROM media_item WHERE id = ?1", + params![media_item_id], + |row| row.get(0), + )?; + + if kind == "movie" { + let row: Option<(String, Option, i64)> = conn + .query_row( + "SELECT m.title, m.year, + (m.anidb_id IS NOT NULL + OR m.tmdb_id IN (SELECT tmdb_id FROM anime_mapping WHERE tmdb_id IS NOT NULL) + OR m.tmdb_id IN (SELECT tmdb_id FROM anime_tmdb_movie)) + FROM media_item m + WHERE m.id = ?1 + AND NOT EXISTS (SELECT 1 FROM episode_file f + WHERE f.media_item_id = m.id AND f.episode_id IS NULL)", + params![media_item_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional()?; + let Some((title, year, is_anime_movie)) = row else { + return Ok(Vec::new()); + }; + let route = if is_anime_movie != 0 { + SearchRoute::NyaaSearch + } else { + SearchRoute::Tpb + }; + return Ok(vec![SearchTarget { + media_item_id, + episode_id: None, + season_number: None, + query: build_movie_query(&title, year), + title_words: significant_words(&title), + route, + upgrade_min_gain: None, + }]); + } + + let mut stmt = conn.prepare( + "SELECT e.id, m.title, e.season_number, e.episode_number + FROM episode e + JOIN media_item m ON m.id = e.media_item_id + WHERE e.media_item_id = ?1 AND e.monitored = 1 AND e.has_file = 0 + AND e.air_date IS NOT NULL AND e.air_date <= date('now') + AND NOT EXISTS (SELECT 1 FROM release r WHERE r.episode_id = e.id + AND r.status IN ('grabbed','downloading')) + ORDER BY e.season_number, e.episode_number", + )?; + let rows: Vec<(i64, String, i64, i64)> = stmt + .query_map(params![media_item_id], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + })? + .collect::>()?; + Ok(rows + .into_iter() + .map(|(episode_id, title, season, episode)| SearchTarget { + media_item_id, + episode_id: Some(episode_id), + season_number: Some(season as u32), + query: build_tv_query(&title, season, episode, SearchRoute::Tpb), + title_words: significant_words(&title), + route: SearchRoute::Tpb, + upgrade_min_gain: None, + }) + .collect()) +} + +pub fn find_media_item_id_by_title(conn: &Connection, title: &str) -> Result> { + conn.query_row( + "SELECT id FROM media_item WHERE title = ?1", + params![title], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) +} + +#[allow(clippy::too_many_arguments)] +pub async fn execute_search_targets( + conn: &Connection, + targets: &[SearchTarget], + tpb: &sources::tpb::TpbSource, + tpb_source_id: i64, + scrape: &sources::scrape::ScrapeSource, + scrape_source_id: i64, + nyaa_search: &sources::rss::RssSource, + nyaa_source_id: i64, + matcher: &mut TitleMatcher, + qbit: &QbitClient, + qbit_category: &str, +) -> Result { + let mut stats = SearchCycleStats { + targets: targets.len(), + ..Default::default() + }; + + let mut consecutive_fetch_errors = 0u32; + // TPB queries are title-only (see `build_tv_query`), so every missing + // episode of the same show produces the *identical* query string — + // without this, a 10-episode backlog fires 10 indistinguishable + // requests at a single-domain API with no mirror fallback, which is + // exactly the kind of pattern that gets a source rate-limited. Caches + // which source actually answered too (the TPB→1337x fallback can mean + // two different targets with the same query string were served by two + // different sources), so a cache hit still attributes dedup/grab + // records to the right source id. + let mut query_cache: std::collections::HashMap< + (SearchRoute, String), + (Vec, i64), + > = std::collections::HashMap::new(); + + for (i, target) in targets.iter().enumerate() { + let (primary, primary_id): (&dyn ReleaseSource, i64) = match target.route { + SearchRoute::Tpb => (tpb, tpb_source_id), + SearchRoute::X1337 => (scrape, scrape_source_id), + SearchRoute::NyaaSearch => (nyaa_search, nyaa_source_id), + }; + + let cache_key = (target.route, target.query.clone()); + let (items, source_id) = + if let Some((cached_items, cached_source_id)) = query_cache.get(&cache_key) { + (cached_items.clone(), *cached_source_id) + } else { + if i > 0 { + let jitter_secs = fastrand::u64(8..=20); + tokio::time::sleep(std::time::Duration::from_secs(jitter_secs)).await; + } + + let primary_result = primary.fetch(Some(&target.query)).await; + // TPB is the primary route for general content, but a fetch + // *failure* there (not just "no relevant results") falls back + // to 1337x for the same query before giving up — keeps the + // mirror-rotation/cooldown machinery built for 1337x as real + // resilience rather than dead code, just no longer the first + // choice given TPB's better precision. `result_source_id` + // tracks which source actually produced whatever we end up + // with, since dedup (`is_seen`/`mark_seen`) and grab records + // are keyed by source id — attributing a 1337x-sourced guid to + // TPB's source id would silently break dedup between the two. + let (fetch_result, result_source_id) = + if primary_result.is_err() && matches!(target.route, SearchRoute::Tpb) { + tracing::warn!( + query = %target.query, + error = %primary_result.as_ref().unwrap_err(), + "TPB search failed, falling back to 1337x" + ); + (scrape.fetch(Some(&target.query)).await, scrape_source_id) + } else { + (primary_result, primary_id) + }; + + match fetch_result { + Ok(items) => { + consecutive_fetch_errors = 0; + query_cache.insert(cache_key, (items.clone(), result_source_id)); + (items, result_source_id) + } + Err(e) => { + consecutive_fetch_errors += 1; + tracing::warn!(query = %target.query, error = %e, "search fetch failed"); + record_target_attempt(conn, target, "error")?; + stats.errors += 1; + let exhausted = e + .downcast_ref::() + .is_some() + || consecutive_fetch_errors >= 2; + if exhausted { + stats.source_exhausted = true; + break; + } + continue; + } + } + }; + + let mut sorted = items; + sorted.sort_by_key(|i| std::cmp::Reverse(i.seeders.unwrap_or(0))); + sorted.truncate(MAX_RESULTS_PER_SEARCH); + + // Computed once per target, over candidates that at least pass the + // cheap relevance pre-filter (not the full title-matcher — running + // that here just to compute this flag would duplicate the real + // matching `process_item` already does per-item below). Used to + // decide whether a sub-1080p candidate is a real downgrade or the + // only option actually available for this target. + let better_resolution_available = sorted.iter().any(|item| { + passes_relevance_filter(&target.title_words, &item.title) + && parser::parse(&item.title) + .resolution + .is_some_and(|r| r >= 1080) + }); + + // A query built for one target's title can surface a *different* + // monitored show/movie in its results (1337x's search isn't tightly + // scoped — related/similarly-tagged content shows up, e.g. "House + // of the Dragon" search results including "Game of Thrones" + // releases). `process_item` re-matches every result independently + // and will correctly grab such an opportunistic hit if it's + // genuinely missing — that's a real bonus, not a bug — but only + // grabbing *this specific target* should mark it satisfied and stop + // the scan; otherwise the target is wrongly recorded as resolved + // while its own release goes unevaluated, potentially forever if + // the same unrelated show keeps outranking it on seeders. + let mut target_satisfied = false; + for item in &sorted { + if is_seen(conn, source_id, &item.guid)? { + continue; + } + // 1337x's search does loose keyword matching, not phrase + // matching — a query for a specific show/movie routinely + // surfaces massively-seeded, completely unrelated top hits that + // merely share one common word (see `passes_relevance_filter`). + // Skip those before they reach the embedding matcher at all. + if !passes_relevance_filter(&target.title_words, &item.title) { + continue; + } + match process_item( + conn, + item, + matcher, + qbit, + qbit_category, + source_id, + better_resolution_available, + target.upgrade_min_gain, + ) + .await + { + // `GateRejected` deliberately does not mark seen: the + // common real case is a release rejected today for too few + // seeders that climbs into viability by the next scheduled + // search — re-evaluating it costs nothing extra (it's + // already in the response just fetched), but permanently + // skipping it would strand the item until a different + // release happens to appear. + Ok(ProcessOutcome::GateRejected(_)) => {} + Ok(outcome) => { + mark_seen(conn, source_id, &item.guid)?; + match outcome { + ProcessOutcome::Grabbed { + media_item_id, + episode_id, + season_number, + .. + } => { + stats.grabbed += 1; + // A season-pack grab has no single `episode_id` + // of its own, but it still satisfies this + // target if the target's own episode falls in + // the season it covers — without this, the + // search cycle wouldn't recognize the pack as + // having answered a single-episode search, and + // would keep re-searching (and potentially + // re-grabbing) the same already-covered episode + // every cycle. + let satisfies_target = (media_item_id == target.media_item_id + && episode_id == target.episode_id) + || (media_item_id == target.media_item_id + && season_number.is_some() + && season_number == target.season_number); + if satisfies_target { + target_satisfied = true; + break; + } + } + ProcessOutcome::QueuedForReview => stats.queued_for_review += 1, + _ => {} + } + } + Err(e) => { + tracing::warn!(error = %e, title = %item.title, "failed to process search result"); + stats.errors += 1; + // Not marked seen — same transient-failure reasoning as + // the feed path (`run_grab_cycle`). + } + } + } + let result = if target_satisfied { + "grabbed" + } else if sorted.is_empty() { + "no_results" + } else { + "no_viable" + }; + record_target_attempt(conn, target, result)?; + stats.searched += 1; + } + + Ok(stats) +} + +fn source_route_name(route: SearchRoute) -> &'static str { + match route { + SearchRoute::Tpb => "tpb", + SearchRoute::X1337 => "1337x", + SearchRoute::NyaaSearch => "nyaa", + } +} + +/// Fetches and scores (or gate-rejects) candidates for one search target — +/// the same evaluation `execute_search_targets` does automatically, minus +/// the grab decision, surfaced instead for a human to choose from. Used by +/// the manual release picker: `episode_id` selects which of a media item's +/// several possible targets (missing episodes, or the movie itself) to +/// search for. Returns an empty list if there's no live target for that +/// episode/movie right now (already owned, unmonitored, or mid-grab) — +/// same "nothing to do" cases `enumerate_search_targets_for_media_item` +/// already excludes. +#[allow(clippy::too_many_arguments)] +pub async fn fetch_candidates( + conn: &Connection, + media_item_id: i64, + episode_id: Option, + tpb: &sources::tpb::TpbSource, + tpb_source_id: i64, + scrape: &sources::scrape::ScrapeSource, + scrape_source_id: i64, + nyaa_search: &sources::rss::RssSource, + nyaa_source_id: i64, +) -> Result> { + let targets = enumerate_search_targets_for_media_item(conn, media_item_id)?; + let Some(target) = targets.into_iter().find(|t| t.episode_id == episode_id) else { + return Ok(Vec::new()); + }; + + let (source, source_id): (&dyn ReleaseSource, i64) = match target.route { + SearchRoute::Tpb => (tpb, tpb_source_id), + SearchRoute::X1337 => (scrape, scrape_source_id), + SearchRoute::NyaaSearch => (nyaa_search, nyaa_source_id), + }; + let items = source.fetch(Some(&target.query)).await?; + + let media_item = get_media_item(conn, media_item_id)?; + let anime = match media_item.tvdb_id { + Some(id) => is_anime(conn, id)?, + None => false, + }; + let profile_kind = if media_item.kind == "movie" { + ProfileKind::Movie + } else { + ProfileKind::Tv + }; + let profile = load_quality_profile(conn, media_item.quality_profile_id, profile_kind)?; + + let mut sorted = items; + sorted.sort_by_key(|i| std::cmp::Reverse(i.seeders.unwrap_or(0))); + sorted.truncate(MAX_RESULTS_PER_SEARCH); + + let better_resolution_available = sorted.iter().any(|item| { + passes_relevance_filter(&target.title_words, &item.title) + && parser::parse(&item.title) + .resolution + .is_some_and(|r| r >= 1080) + }); + + let mut candidates = Vec::new(); + for item in &sorted { + if !passes_relevance_filter(&target.title_words, &item.title) { + continue; + } + let parsed = parser::parse(&item.title); + let is_season_pack = looks_like_season_pack(&parsed); + let gate_ctx = GateContext { + seeders: item.seeders, + size_bytes: item.size_bytes, + runtime_minutes: None, + has_english_audio: infer_has_english_audio(&item.title), + is_anime: anime, + better_resolution_available, + is_season_pack, + }; + let (score, rejected_reason) = match scoring::evaluate_gates(&parsed, &gate_ctx, &profile) { + GateResult::Accept => ( + Some(scoring::score( + &parsed, + item.seeders.unwrap_or(0), + false, + &profile, + )), + None, + ), + GateResult::Reject(reason) => (None, Some(format!("{reason:?}"))), + }; + candidates.push(breadarr_shared::dto::ReleaseCandidate { + raw_title: item.title.clone(), + link: item.link.clone(), + guid: item.guid.clone(), + source_id, + source_name: source_route_name(target.route).to_string(), + seeders: item.seeders, + leechers: item.leechers, + size_bytes: item.size_bytes, + score, + rejected_reason, + resolution: parsed.resolution, + is_repack: parsed.is_repack, + is_season_pack, + }); + } + candidates.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + Ok(candidates) +} + +/// Grabs a specific candidate a human picked from `fetch_candidates`' +/// output, bypassing the score-vs-existing-best `should_grab` comparison +/// entirely — a manual pick is an explicit override, not a competing +/// automatic decision. Whether the chosen release is a season pack is +/// re-derived from its own title rather than trusted from the candidate +/// list (the two must agree by construction, but re-deriving here means +/// this function's correctness doesn't depend on a caller passing the +/// right flag back). +#[allow(clippy::too_many_arguments)] +pub async fn grab_candidate( + conn: &Connection, + qbit: &QbitClient, + qbit_category: &str, + media_item_id: i64, + episode_id: Option, + source_id: i64, + raw_title: &str, + link: &str, + guid: &str, +) -> Result<()> { + let parsed = parser::parse(raw_title); + let media_item = get_media_item(conn, media_item_id)?; + let profile_kind = if media_item.kind == "movie" { + ProfileKind::Movie + } else { + ProfileKind::Tv + }; + let profile = load_quality_profile(conn, media_item.quality_profile_id, profile_kind)?; + let season_pack_number = if looks_like_season_pack(&parsed) { + parsed.season + } else { + None + }; + let final_episode_id = if season_pack_number.is_some() { + None + } else { + episode_id + }; + // Real-time seeder data isn't available for a candidate picked from an + // earlier fetch — same `seeders=0` fallback `finalize_review_approval` + // already uses for the same reason, and for the same reason it's still + // real signal from resolution/source/codec/etc., not a meaningless + // hardcoded score. + let score = scoring::score(&parsed, 0, false, &profile); + + let torrent_hash = match grab_and_capture_hash(qbit, link, qbit_category).await { + Ok(hash) => hash, + Err(e) if e.downcast_ref::().is_some() => { + anyhow::bail!("qBittorrent rejected this release's magnet/torrent"); + } + Err(e) => return Err(e), + }; + let status = if torrent_hash.is_some() { + "grabbed" + } else { + "failed" + }; + record_grab( + conn, + media_item_id, + final_episode_id, + season_pack_number, + source_id, + raw_title, + guid, + score, + None, + qbit_category, + torrent_hash.as_deref(), + status, + )?; + Ok(()) +} + +struct ReviewQueueRow { + raw_release_title: String, + candidate_media_item_id: Option, + link: Option, + source_id: Option, + status: String, +} + +fn get_review_row(conn: &Connection, review_id: i64) -> Result { + conn.query_row( + "SELECT raw_release_title, candidate_media_item_id, link, source_id, status FROM review_queue WHERE id = ?1", + params![review_id], + |row| { + Ok(ReviewQueueRow { + raw_release_title: row.get(0)?, + candidate_media_item_id: row.get(1)?, + link: row.get(2)?, + source_id: row.get(3)?, + status: row.get(4)?, + }) + }, + ) + .map_err(Into::into) +} + +pub struct PreparedApproval { + pub media_item_id: i64, + pub episode_id: Option, + /// Set only when this review item resolved to a season-pack grab — see + /// `release.season_number`'s own doc comment for why `episode_id IS + /// NULL` alone can't disambiguate which season. + pub season_number: Option, + pub source_id: i64, + pub raw_release_title: String, + pub link: String, + /// Computed from the parsed title with `seeders=0` (real-time seeder + /// data isn't available for a review-queue entry by approval time) — + /// still real signal from resolution/source/codec/etc., unlike the + /// hardcoded `0.0` this replaced, which made *any* future automatic + /// candidate with a nonzero score look like an upgrade over a release a + /// human had just explicitly confirmed. + pub score: f32, +} + +pub enum ApprovalPrep { + Ready(PreparedApproval), + NotPending, + MissingGrabData, + CouldNotResolveEpisode, + NotMonitoredOrAlreadyHave, +} + +/// Sync-only: reads everything needed to grab the reviewed release. Split +/// from the actual grab (which needs `&QbitClient` and `.await`s) because a +/// `rusqlite::Connection` isn't `Sync` — holding a reference to it across an +/// `.await` makes the enclosing future `!Send`, which axum's handler trait +/// requires. Callers using a shared `Mutex` (e.g. the HTTP API) +/// must drop their lock guard between calling this and awaiting the grab. +pub fn prepare_review_approval(conn: &Connection, review_id: i64) -> Result { + let row = get_review_row(conn, review_id)?; + if row.status != "pending" { + return Ok(ApprovalPrep::NotPending); + } + let (Some(media_item_id), Some(link), Some(source_id)) = + (row.candidate_media_item_id, row.link, row.source_id) + else { + return Ok(ApprovalPrep::MissingGrabData); + }; + + let media_item = get_media_item(conn, media_item_id)?; + let parsed = parser::parse(&row.raw_release_title); + + let mut episode_id: Option = None; + let mut season_pack_number: Option = None; + + if media_item.kind == "movie" { + if looks_like_episode(&parsed) { + return Ok(ApprovalPrep::CouldNotResolveEpisode); + } + if movie_year_mismatch(parsed.year, media_item.year) { + return Ok(ApprovalPrep::CouldNotResolveEpisode); + } + if !movie_needs_grab(conn, media_item.id)? { + return Ok(ApprovalPrep::NotMonitoredOrAlreadyHave); + } + } else if looks_like_season_pack(&parsed) { + let Some(season) = parsed.season else { + return Ok(ApprovalPrep::CouldNotResolveEpisode); + }; + if count_monitored_missing_episodes_in_season(conn, media_item.id, season)? == 0 { + return Ok(ApprovalPrep::NotMonitoredOrAlreadyHave); + } + season_pack_number = Some(season); + } else { + let Some((season, episode)) = resolve_episode(conn, media_item.tvdb_id, &parsed)? else { + return Ok(ApprovalPrep::CouldNotResolveEpisode); + }; + let Some(eid) = find_monitored_missing_episode(conn, media_item.id, season, episode)? + else { + return Ok(ApprovalPrep::NotMonitoredOrAlreadyHave); + }; + episode_id = Some(eid); + } + + let profile_kind = if media_item.kind == "movie" { + ProfileKind::Movie + } else { + ProfileKind::Tv + }; + let profile = load_quality_profile(conn, media_item.quality_profile_id, profile_kind)?; + let score = scoring::score(&parsed, 0, false, &profile); + + Ok(ApprovalPrep::Ready(PreparedApproval { + media_item_id: media_item.id, + episode_id, + season_number: season_pack_number, + source_id, + raw_release_title: row.raw_release_title, + link, + score, + })) +} + +/// The actual grab — network/qBittorrent I/O only, no `Connection` involved, +/// safe to `.await` from anywhere. +pub async fn grab_prepared_approval( + qbit: &QbitClient, + qbit_category: &str, + prepared: &PreparedApproval, +) -> Result> { + grab_and_capture_hash(qbit, &prepared.link, qbit_category).await +} + +/// Sync-only: records the grab and marks the review approved. Call after +/// [`grab_prepared_approval`] completes. +pub fn finalize_review_approval( + conn: &Connection, + review_id: i64, + prepared: &PreparedApproval, + qbit_category: &str, + torrent_hash: Option<&str>, +) -> Result<()> { + // Same reasoning as `process_item`'s `HashCaptureFailed` handling: a + // `grabbed` row with a NULL hash is invisible to the importer and + // strands the episode/movie forever, so an uncorrelated add is recorded + // as `failed` instead, leaving it free to be grabbed again later. + let status = if torrent_hash.is_some() { + "grabbed" + } else { + "failed" + }; + record_grab( + conn, + prepared.media_item_id, + prepared.episode_id, + prepared.season_number, + prepared.source_id, + &prepared.raw_release_title, + &prepared.link, + prepared.score, + // No size available for a review-queue entry (the review row + // carries no size_bytes) — this is the one `record_grab` call site + // where `torrent_fetch.size_bytes` is legitimately NULL. + None, + qbit_category, + torrent_hash, + status, + )?; + conn.execute( + "UPDATE review_queue SET status = 'approved' WHERE id = ?1", + params![review_id], + )?; + Ok(()) +} + +pub fn reject_review(conn: &Connection, review_id: i64) -> Result<()> { + conn.execute( + "UPDATE review_queue SET status = 'rejected' WHERE id = ?1 AND status = 'pending'", + params![review_id], + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_grab_when_nothing_exists_yet() { + assert!(should_grab(10.0, false, None, 0.0)); + } + + #[test] + fn should_grab_when_strictly_better_score() { + assert!(should_grab(15.0, false, Some(10.0), 0.0)); + } + + #[test] + fn should_not_grab_when_worse_or_equal_score() { + assert!(!should_grab(10.0, false, Some(10.0), 0.0)); + assert!(!should_grab(5.0, false, Some(10.0), 0.0)); + } + + #[test] + fn should_grab_repack_even_if_not_higher_scored() { + assert!(should_grab(10.0, true, Some(10.0), 0.0)); + assert!(should_grab(8.0, true, Some(10.0), 0.0)); + } + + #[test] + fn should_not_grab_when_score_gain_is_below_the_minimum_threshold() { + // A 3-point improvement doesn't clear a 5-point minimum gain. + assert!(!should_grab(13.0, false, Some(10.0), 5.0)); + // An 8-point improvement does. + assert!(should_grab(18.0, false, Some(10.0), 5.0)); + } + + #[test] + fn should_grab_repack_even_below_the_minimum_gain_threshold() { + assert!(should_grab(10.0, true, Some(10.0), 5.0)); + } + + #[test] + fn infers_english_audio_present_by_default() { + assert!(infer_has_english_audio( + "Some Show S01E01 1080p WEB-DL H264" + )); + } + + #[test] + fn infers_no_english_audio_for_vostfr() { + assert!(!infer_has_english_audio( + "Some Show S01E01 VOSTFR 1080p WEB x264" + )); + } + + fn seeded_conn() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, tvdb_id, monitored, quality_profile_id, root_folder) + VALUES (1, 'series', 'Some Show', 12345, 1, 1, '/tmp')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode (media_item_id, season_number, episode_number, monitored, has_file) + VALUES (1, 1, 1, 1, 0)", + [], + ) + .unwrap(); + conn + } + + #[test] + fn finds_a_monitored_missing_episode() { + let conn = seeded_conn(); + let episode_id = find_monitored_missing_episode(&conn, 1, 1, 1).unwrap(); + assert!(episode_id.is_some()); + } + + /// Two owned, monitored, equally-due episodes — one with a real + /// probe-verified problem, one clean. A budget of 1 forces the ordering + /// to matter: the flagged one must come back, not whichever happened to + /// be inserted (or aired) first. + fn seeded_upgrade_conn_with_one_flagged_episode() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, monitored, quality_profile_id, root_folder) + VALUES (1, 'series', 'Some Show', 1, 1, '/tmp')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file) + VALUES (1, 1, 1, 1, 1, 1)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file) + VALUES (2, 1, 1, 2, 1, 1)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (1, 1, 1, '/tmp/s01e01.mkv', 100, 'none')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode_file (id, episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (2, 2, 1, '/tmp/s01e02.mkv', 100, 'none')", + [], + ) + .unwrap(); + // Episode 1's file: clean, nothing flagged. + conn.execute( + "INSERT INTO media_file_probe (episode_file_id, probed_at, probe_size_bytes, probe_mtime, corruption_status) + VALUES (1, datetime('now'), 100, 0, 'probe_ok')", + [], + ) + .unwrap(); + // Episode 2's file: probe-verified under-quality. + conn.execute( + "INSERT INTO media_file_probe (episode_file_id, probed_at, probe_size_bytes, probe_mtime, corruption_status, flag_under_quality) + VALUES (2, datetime('now'), 100, 0, 'probe_ok', 1)", + [], + ) + .unwrap(); + conn + } + + #[test] + fn enumerate_upgrade_targets_prioritizes_a_probe_flagged_episode_over_a_clean_one() { + let conn = seeded_upgrade_conn_with_one_flagged_episode(); + let targets = enumerate_upgrade_targets(&conn, 1, 5.0).unwrap(); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].episode_id, Some(2)); + } + + #[test] + fn enumerate_upgrade_targets_returns_both_when_budget_allows() { + let conn = seeded_upgrade_conn_with_one_flagged_episode(); + let targets = enumerate_upgrade_targets(&conn, 10, 5.0).unwrap(); + assert_eq!(targets.len(), 2); + // Flagged episode still sorts first even when both fit. + assert_eq!(targets[0].episode_id, Some(2)); + assert_eq!(targets[1].episode_id, Some(1)); + } + + #[test] + fn record_grab_writes_both_a_release_row_and_a_torrent_fetch_audit_row() { + let conn = seeded_conn(); + conn.execute( + "INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'https://example.invalid')", + [], + ) + .unwrap(); + + record_grab( + &conn, + 1, + Some(1), + None, + 1, + "Some.Show.S01E01.1080p", + "guid-1", + 8.5, + Some(1_234_567_890), + "breadarr", + Some("deadbeef"), + "grabbed", + ) + .unwrap(); + + let release_count: i64 = conn + .query_row("SELECT count(*) FROM release", [], |r| r.get(0)) + .unwrap(); + assert_eq!(release_count, 1); + + let (hash, name, size, category, status): ( + Option, + String, + Option, + Option, + String, + ) = conn + .query_row( + "SELECT torrent_hash, name, size_bytes, category, status FROM torrent_fetch", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)), + ) + .unwrap(); + assert_eq!(hash.as_deref(), Some("deadbeef")); + assert_eq!(name, "Some.Show.S01E01.1080p"); + assert_eq!(size, Some(1_234_567_890)); + assert_eq!(category.as_deref(), Some("breadarr")); + assert_eq!(status, "grabbed"); + } + + #[test] + fn record_grab_still_logs_to_torrent_fetch_when_hash_capture_failed() { + let conn = seeded_conn(); + conn.execute( + "INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'https://example.invalid')", + [], + ) + .unwrap(); + + record_grab( + &conn, + 1, + Some(1), + None, + 1, + "Some.Show.S01E01.1080p", + "guid-1", + 8.5, + None, + "breadarr", + None, + "failed", + ) + .unwrap(); + + let (hash, status): (Option, String) = conn + .query_row("SELECT torrent_hash, status FROM torrent_fetch", [], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) + .unwrap(); + assert_eq!(hash, None); + assert_eq!(status, "failed"); + } + + fn insert_pending_review(conn: &Connection, raw_title: &str) -> i64 { + conn.execute( + "INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'https://example.invalid')", + [], + ) + .ok(); // ignore if a test already inserted source id 1 + conn.execute( + "INSERT INTO review_queue (raw_release_title, candidate_media_item_id, confidence, link, source_id, status, created_at) + VALUES (?1, 1, 0.5, 'magnet:?xt=urn:btih:deadbeef', 1, 'pending', datetime('now'))", + params![raw_title], + ) + .unwrap(); + conn.last_insert_rowid() + } + + #[test] + fn does_not_find_an_unmonitored_or_already_have_episode() { + let conn = seeded_conn(); + assert!(find_monitored_missing_episode(&conn, 1, 1, 2) + .unwrap() + .is_none()); + + conn.execute( + "UPDATE episode SET has_file = 1 WHERE season_number = 1 AND episode_number = 1", + [], + ) + .unwrap(); + assert!(find_monitored_missing_episode(&conn, 1, 1, 1) + .unwrap() + .is_none()); + } + + #[test] + fn resolves_direct_season_episode_without_anime_map() { + let conn = seeded_conn(); + let parsed = parser::parse("Some Show S01E05 1080p WEB-DL"); + assert_eq!( + resolve_episode(&conn, Some(12345), &parsed).unwrap(), + Some((1, 5)) + ); + } + + #[test] + fn resolves_absolute_episode_via_anime_map() { + let conn = seeded_conn(); + conn.execute( + "INSERT INTO anime_mapping (anidb_id, tvdb_id, season_offset, episode_offset) VALUES (1, 12345, 1, 0)", + [], + ) + .unwrap(); + let parsed = parser::parse("[Group] Some Show - 07 [1080p]"); + assert_eq!( + resolve_episode(&conn, Some(12345), &parsed).unwrap(), + Some((1, 7)) + ); + } + + fn seeded_movie_conn() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, year, tmdb_id, monitored, quality_profile_id, root_folder) + VALUES (1, 'movie', 'Some Movie', 2016, 98765, 1, 2, '/tmp')", + [], + ) + .unwrap(); + conn + } + + #[test] + fn load_quality_profile_uses_defaults_for_the_seeded_empty_weights_row() { + let conn = seeded_movie_conn(); + // db::init seeds quality_profile id=2 ("Default Movie") with weights='{}'. + let profile = load_quality_profile(&conn, 2, ProfileKind::Movie).unwrap(); + assert_eq!(profile.weights.hdr, 2.0); + assert_eq!(profile.weights.resolution_tier, 3.0); + } + + #[test] + fn load_quality_profile_applies_a_stored_override() { + let conn = seeded_movie_conn(); + conn.execute( + "UPDATE quality_profile SET weights = '{\"hdr\": 8.0}' WHERE id = 2", + [], + ) + .unwrap(); + let profile = load_quality_profile(&conn, 2, ProfileKind::Movie).unwrap(); + assert_eq!(profile.weights.hdr, 8.0); + // Untouched axes still come from the built-in movie default. + assert_eq!(profile.weights.resolution_tier, 3.0); + } + + #[test] + fn movie_needs_grab_when_monitored_and_missing() { + let conn = seeded_movie_conn(); + assert!(movie_needs_grab(&conn, 1).unwrap()); + } + + #[test] + fn prepares_a_movie_review_approval_with_no_episode_id() { + let conn = seeded_movie_conn(); + let review_id = insert_pending_review(&conn, "Some Movie 2016 1080p BluRay x264"); + + match prepare_review_approval(&conn, review_id).unwrap() { + ApprovalPrep::Ready(prepared) => { + assert_eq!(prepared.media_item_id, 1); + assert_eq!(prepared.episode_id, None); + } + _ => panic!("expected ApprovalPrep::Ready for a monitored, file-less movie"), + } + } + + #[test] + fn rejects_a_movie_review_whose_title_looks_like_an_episode() { + let conn = seeded_movie_conn(); + let review_id = insert_pending_review(&conn, "Some Movie S01E02 1080p WEB-DL"); + + assert!(matches!( + prepare_review_approval(&conn, review_id).unwrap(), + ApprovalPrep::CouldNotResolveEpisode + )); + } + + #[test] + fn rejects_a_movie_review_with_a_mismatched_year() { + let conn = seeded_movie_conn(); + let review_id = insert_pending_review(&conn, "Some Movie (1999) 1080p BluRay x264"); + + assert!(matches!( + prepare_review_approval(&conn, review_id).unwrap(), + ApprovalPrep::CouldNotResolveEpisode + )); + } + + #[test] + fn movie_does_not_need_grab_when_unmonitored() { + let conn = seeded_movie_conn(); + conn.execute("UPDATE media_item SET monitored = 0 WHERE id = 1", []) + .unwrap(); + assert!(!movie_needs_grab(&conn, 1).unwrap()); + } + + #[test] + fn movie_does_not_need_grab_once_it_has_a_file() { + let conn = seeded_movie_conn(); + conn.execute( + "INSERT INTO episode_file (media_item_id, episode_id, path, size_bytes) + VALUES (1, NULL, '/tmp/movie.mkv', 100)", + [], + ) + .unwrap(); + assert!(!movie_needs_grab(&conn, 1).unwrap()); + } + + #[test] + fn movie_does_not_need_grab_while_a_release_is_in_flight() { + let conn = seeded_movie_conn(); + conn.execute( + "INSERT INTO source (id, name, kind, base_url) VALUES (1, 'test', 'scrape', 'http://x')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO release (media_item_id, episode_id, raw_title, source_id, guid, status, grabbed_at) + VALUES (1, NULL, 'Some Movie 2016', 1, 'guid-1', 'grabbed', datetime('now'))", + [], + ) + .unwrap(); + assert!(!movie_needs_grab(&conn, 1).unwrap()); + } + + #[test] + fn movie_eligible_for_upgrade_is_true_once_it_already_has_a_file() { + let conn = seeded_movie_conn(); + conn.execute( + "INSERT INTO episode_file (media_item_id, episode_id, path, size_bytes) + VALUES (1, NULL, '/tmp/movie.mkv', 100)", + [], + ) + .unwrap(); + // Unlike `movie_needs_grab`, having a file already doesn't disqualify + // an upgrade check — that's the whole point of the upgrade path. + assert!(movie_eligible_for_upgrade(&conn, 1).unwrap()); + } + + #[test] + fn movie_eligible_for_upgrade_is_false_when_unmonitored() { + let conn = seeded_movie_conn(); + conn.execute("UPDATE media_item SET monitored = 0 WHERE id = 1", []) + .unwrap(); + assert!(!movie_eligible_for_upgrade(&conn, 1).unwrap()); + } + + #[test] + fn movie_eligible_for_upgrade_is_false_while_a_release_is_in_flight() { + let conn = seeded_movie_conn(); + conn.execute( + "INSERT INTO source (id, name, kind, base_url) VALUES (1, 'test', 'scrape', 'http://x')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO release (media_item_id, episode_id, raw_title, source_id, guid, status, grabbed_at) + VALUES (1, NULL, 'Some Movie 2016', 1, 'guid-1', 'downloading', datetime('now'))", + [], + ) + .unwrap(); + assert!(!movie_eligible_for_upgrade(&conn, 1).unwrap()); + } + + #[test] + fn find_monitored_episode_ignores_has_file_but_still_requires_monitored() { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, monitored, quality_profile_id, root_folder) + VALUES (1, 'series', 'Some Show', 1, 1, '/tmp')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode (media_item_id, season_number, episode_number, monitored, has_file) + VALUES (1, 1, 1, 1, 1)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode (media_item_id, season_number, episode_number, monitored, has_file) + VALUES (1, 1, 2, 0, 1)", + [], + ) + .unwrap(); + + assert!(find_monitored_episode(&conn, 1, 1, 1).unwrap().is_some()); + assert!(find_monitored_episode(&conn, 1, 1, 2).unwrap().is_none()); + } + + #[test] + fn looks_like_episode_detects_any_episode_marker() { + assert!(!looks_like_episode(&parser::parse( + "Some Movie 2016 1080p BluRay" + ))); + assert!(looks_like_episode(&parser::parse( + "Some Show S01E02 1080p WEB-DL" + ))); + assert!(looks_like_episode(&parser::parse( + "[Group] Some Show - 07 [1080p]" + ))); + } + + #[test] + fn looks_like_season_pack_detects_batch_releases() { + assert!(looks_like_season_pack(&parser::parse( + "Some Show (S01 Complete) 1080p WEB-DL" + ))); + assert!(looks_like_season_pack(&parser::parse( + "Some Show S01E01-E12 1080p WEB-DL" + ))); + } + + #[test] + fn looks_like_season_pack_is_false_for_a_single_episode() { + assert!(!looks_like_season_pack(&parser::parse( + "Some Show S01E02 1080p WEB-DL" + ))); + assert!(!looks_like_season_pack(&parser::parse( + "Some Movie 2016 1080p BluRay" + ))); + } + + #[test] + fn count_monitored_missing_episodes_in_season_counts_correctly() { + let conn = seeded_conn(); + // seeded_conn already has one monitored, missing episode (S01E01). + conn.execute( + "INSERT INTO episode (media_item_id, season_number, episode_number, monitored, has_file) + VALUES (1, 1, 2, 1, 0)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode (media_item_id, season_number, episode_number, monitored, has_file) + VALUES (1, 1, 3, 1, 1)", // already has a file, shouldn't count + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode (media_item_id, season_number, episode_number, monitored, has_file) + VALUES (1, 1, 4, 0, 0)", // unmonitored, shouldn't count + [], + ) + .unwrap(); + assert_eq!( + count_monitored_missing_episodes_in_season(&conn, 1, 1).unwrap(), + 2 + ); + assert_eq!( + count_monitored_missing_episodes_in_season(&conn, 1, 2).unwrap(), + 0 + ); + } + + #[test] + fn find_episode_id_ignores_monitored_and_has_file_state() { + let conn = seeded_conn(); + conn.execute( + "UPDATE episode SET monitored = 0, has_file = 1 WHERE media_item_id = 1 AND season_number = 1 AND episode_number = 1", + [], + ) + .unwrap(); + // find_monitored_missing_episode should now see nothing... + assert!(find_monitored_missing_episode(&conn, 1, 1, 1) + .unwrap() + .is_none()); + // ...but find_episode_id (identity-only lookup) still finds it. + assert!(find_episode_id(&conn, 1, 1, 1).unwrap().is_some()); + } + + #[test] + fn looks_like_non_video_format_catches_common_ebook_and_comic_markers() { + assert!(looks_like_non_video_format( + "Boy Swallows Universe by Trent Dalton EPUB" + )); + assert!(looks_like_non_video_format( + "Mushoku Tensei Jobless Reincarnation (Light Novel) by Rifujin na Magonote EPUB" + )); + assert!(looks_like_non_video_format( + "Batman 001 (2020) (Digital) (CBR)" + )); + assert!(!looks_like_non_video_format( + "Boy Swallows Universe S01 COMPLETE 720p WEBRip x264" + )); + assert!(!looks_like_non_video_format( + "Some Movie 2016 1080p BluRay x264" + )); + } + + #[test] + fn movie_year_mismatch_rejects_far_apart_years_only() { + assert!(!movie_year_mismatch(None, Some(2016))); + assert!(!movie_year_mismatch(Some(2016), None)); + assert!(!movie_year_mismatch(Some(2016), Some(2016))); + assert!(!movie_year_mismatch(Some(2016), Some(2017))); + assert!(movie_year_mismatch(Some(1984), Some(2021))); + } + + #[test] + fn sanitize_query_text_strips_punctuation() { + assert_eq!( + sanitize_query_text("Kill: Ao / Blue? Robot's Revenge"), + "Kill Ao Blue Robot s Revenge" + ); + } + + #[test] + fn build_tv_query_formats_season_episode_for_x1337() { + assert_eq!( + build_tv_query("Some Show", 4, 13, SearchRoute::X1337), + "Some Show S04E13" + ); + } + + #[test] + fn build_tv_query_is_title_only_for_tpb() { + // apibay's search returns zero results once a season/episode token + // is appended to a multi-word title, even when genuine matches for + // that exact episode exist under a plain title search — verified + // live against real show data. + assert_eq!( + build_tv_query("Some Show", 4, 13, SearchRoute::Tpb), + "Some Show" + ); + } + + #[test] + fn build_movie_query_appends_year_when_known() { + assert_eq!(build_movie_query("Arrival", Some(2016)), "Arrival 2016"); + assert_eq!(build_movie_query("Arrival", None), "Arrival"); + } + + /// A richer fixture than `seeded_conn`/`seeded_movie_conn` — covers both + /// TV and movies at once, plus the specific rows each enumeration + /// predicate needs to exclude. + fn search_enumeration_conn() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO source (id, name, kind, base_url) VALUES (1, 'test-source', 'scrape', 'http://x')", + [], + ) + .unwrap(); + + // A due, monitored, aired, non-anime episode — should be enumerated. + conn.execute( + "INSERT INTO media_item (id, kind, title, tvdb_id, monitored, quality_profile_id, root_folder) + VALUES (1, 'series', 'Some Show', 111, 1, 1, '/tmp')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file, air_date) + VALUES (1, 1, 1, 1, 1, 0, '2020-01-01')", + [], + ) + .unwrap(); + + // An unaired episode of the same show — excluded. + conn.execute( + "INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file, air_date) + VALUES (2, 1, 1, 2, 1, 0, '2999-01-01')", + [], + ) + .unwrap(); + + // An aired episode already mid-grab — excluded. + conn.execute( + "INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file, air_date) + VALUES (3, 1, 1, 3, 1, 0, '2020-01-01')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO release (media_item_id, episode_id, raw_title, source_id, guid, status, grabbed_at) + VALUES (1, 3, 'x', 1, 'guid-inflight', 'grabbed', datetime('now'))", + [], + ) + .unwrap(); + + // An anime series — its missing episode is excluded (stays on nyaa RSS). + conn.execute( + "INSERT INTO media_item (id, kind, title, tvdb_id, monitored, quality_profile_id, root_folder) + VALUES (2, 'series', 'Some Anime', 222, 1, 1, '/tmp')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO anime_mapping (anidb_id, tvdb_id, season_offset, episode_offset) VALUES (1, 222, 1, 0)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file, air_date) + VALUES (4, 2, 1, 1, 1, 0, '2020-01-01')", + [], + ) + .unwrap(); + + // A due, monitored, missing movie (non-anime route). + conn.execute( + "INSERT INTO media_item (id, kind, title, year, tmdb_id, monitored, quality_profile_id, root_folder) + VALUES (3, 'movie', 'Some Movie', 2016, 333, 1, 2, '/tmp')", + [], + ) + .unwrap(); + + // A movie already on disk — excluded. + conn.execute( + "INSERT INTO media_item (id, kind, title, year, tmdb_id, monitored, quality_profile_id, root_folder) + VALUES (4, 'movie', 'Owned Movie', 2010, 444, 1, 2, '/tmp')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode_file (media_item_id, episode_id, path, size_bytes) + VALUES (4, NULL, '/tmp/owned.mkv', 100)", + [], + ) + .unwrap(); + + // An anime movie — routed to nyaa search instead of 1337x. + conn.execute( + "INSERT INTO media_item (id, kind, title, year, anidb_id, monitored, quality_profile_id, root_folder) + VALUES (5, 'movie', 'Some Anime Movie', 2019, 555, 1, 2, '/tmp')", + [], + ) + .unwrap(); + + // An anime movie known only via `anime_tmdb_movie` (no anidb_id, + // not in `anime_mapping` either) — the case that was silently + // broken before `anime_map::refresh` started reading the + // Fribb dataset's `movie` id list (verified live: "Your Name" + // routed to TPB instead of nyaa until this was fixed). + conn.execute( + "INSERT INTO media_item (id, kind, title, year, tmdb_id, monitored, quality_profile_id, root_folder) + VALUES (6, 'movie', 'Some Other Anime Movie', 2016, 666, 1, 2, '/tmp')", + [], + ) + .unwrap(); + conn.execute("INSERT INTO anime_tmdb_movie (tmdb_id) VALUES (666)", []) + .unwrap(); + + conn + } + + #[test] + fn enumerate_search_targets_excludes_unaired_inflight_and_anime() { + let conn = search_enumeration_conn(); + let targets = enumerate_search_targets(&conn, 100).unwrap(); + + assert!(targets.iter().any(|t| t.episode_id == Some(1))); + assert!( + !targets.iter().any(|t| t.episode_id == Some(2)), + "unaired episode should be excluded" + ); + assert!( + !targets.iter().any(|t| t.episode_id == Some(3)), + "in-flight episode should be excluded" + ); + assert!( + !targets.iter().any(|t| t.episode_id == Some(4)), + "anime episode should be excluded" + ); + } + + #[test] + fn enumerate_search_targets_for_media_item_routes_anime_movie_via_tmdb_table() { + let conn = search_enumeration_conn(); + // Same live-verified case ("Your Name") as the manual search-now + // API route: `enumerate_search_targets_for_media_item` is a + // separate query from the batch version above and must apply the + // same `anime_tmdb_movie` check independently. + let targets = enumerate_search_targets_for_media_item(&conn, 6).unwrap(); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].route, SearchRoute::NyaaSearch); + + let regular = enumerate_search_targets_for_media_item(&conn, 3).unwrap(); + assert_eq!(regular.len(), 1); + assert_eq!(regular[0].route, SearchRoute::Tpb); + } + + #[test] + fn enumerate_search_targets_excludes_owned_movies_includes_missing() { + let conn = search_enumeration_conn(); + let targets = enumerate_search_targets(&conn, 100).unwrap(); + + assert!(targets + .iter() + .any(|t| t.media_item_id == 3 && t.episode_id.is_none())); + assert!( + !targets.iter().any(|t| t.media_item_id == 4), + "movie that already has a file should be excluded" + ); + } + + #[test] + fn enumerate_search_targets_routes_anime_movies_to_nyaa_search() { + let conn = search_enumeration_conn(); + let targets = enumerate_search_targets(&conn, 100).unwrap(); + let anime_movie = targets + .iter() + .find(|t| t.media_item_id == 5) + .expect("anime movie should be enumerated"); + assert_eq!(anime_movie.route, SearchRoute::NyaaSearch); + + let anime_movie_via_tmdb_table = targets + .iter() + .find(|t| t.media_item_id == 6) + .expect("anime_tmdb_movie-only anime movie should be enumerated"); + assert_eq!(anime_movie_via_tmdb_table.route, SearchRoute::NyaaSearch); + + let regular_movie = targets + .iter() + .find(|t| t.media_item_id == 3) + .expect("regular movie should be enumerated"); + assert_eq!(regular_movie.route, SearchRoute::Tpb); + } + + #[test] + fn enumerate_search_targets_respects_budget() { + let conn = search_enumeration_conn(); + let targets = enumerate_search_targets(&conn, 1).unwrap(); + assert_eq!(targets.len(), 1); + } + + #[test] + fn record_search_attempt_is_due_again_only_after_cadence_elapses() { + let conn = search_enumeration_conn(); + + // Freshly searched — cadence for a first attempt is 6h, so it must + // not reappear immediately. + record_search_attempt(&conn, 1, Some(1), "no_results").unwrap(); + let targets = enumerate_search_targets(&conn, 100).unwrap(); + assert!( + !targets.iter().any(|t| t.episode_id == Some(1)), + "just-searched episode should not be due yet" + ); + + // Back-date the attempt past the 6h cadence window. + conn.execute( + "UPDATE search_state SET last_searched_at = datetime('now', '-7 hours') WHERE episode_id = 1", + [], + ) + .unwrap(); + let targets = enumerate_search_targets(&conn, 100).unwrap(); + assert!( + targets.iter().any(|t| t.episode_id == Some(1)), + "episode past its cadence window should be due again" + ); + } + + #[test] + fn cadence_stays_backed_off_at_a_high_search_count_instead_of_overflowing() { + // Regression test for a real SQLite integer overflow: uncapped, + // `1 << (search_count - 1)` goes negative at search_count=64 (64-bit + // signed shift), which flipped a chronically-failing target from + // "back off weekly" to "due every single cycle" — the opposite of + // the intended behavior, and worst exactly when backing off matters + // most. + let conn = search_enumeration_conn(); + record_search_attempt(&conn, 1, Some(1), "no_results").unwrap(); + conn.execute( + "UPDATE search_state SET search_count = 64, last_searched_at = datetime('now', '-1 hour') WHERE episode_id = 1", + [], + ) + .unwrap(); + let targets = enumerate_search_targets(&conn, 100).unwrap(); + assert!( + !targets.iter().any(|t| t.episode_id == Some(1)), + "a target searched only 1 hour ago must not be due again yet, \ + regardless of how high its search_count has climbed" + ); + } + + #[test] + fn record_search_attempt_upserts_rather_than_duplicating() { + let conn = search_enumeration_conn(); + record_search_attempt(&conn, 3, None, "no_results").unwrap(); + record_search_attempt(&conn, 3, None, "no_viable").unwrap(); + + let (count, result): (i64, String) = conn + .query_row( + "SELECT search_count, last_result FROM search_state WHERE media_item_id = 3 AND episode_id IS NULL", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(count, 2); + assert_eq!(result, "no_viable"); + } + + #[test] + fn enumerate_search_targets_prioritizes_never_searched_first() { + let conn = search_enumeration_conn(); + // Mark the episode as already searched (but due again) so it's no + // longer in the "never searched" group. + conn.execute( + "INSERT INTO search_state (media_item_id, episode_id, last_searched_at, search_count, last_result) + VALUES (1, 1, datetime('now', '-7 hours'), 1, 'no_results')", + [], + ) + .unwrap(); + + let targets = enumerate_search_targets(&conn, 100).unwrap(); + let episode_pos = targets + .iter() + .position(|t| t.episode_id == Some(1)) + .unwrap(); + let never_searched_movie_pos = targets + .iter() + .position(|t| t.media_item_id == 3 && t.episode_id.is_none()) + .unwrap(); + assert!( + never_searched_movie_pos < episode_pos, + "a never-searched item should sort before an overdue-but-already-searched one" + ); + } + + #[test] + fn significant_words_drops_short_and_punctuation_only_tokens() { + assert_eq!( + significant_words("Georgie & Mandy's First Marriage"), + vec!["georgie", "first", "marriage"] + ); + } + + #[test] + fn relevance_filter_rejects_titles_missing_a_significant_word() { + let words = significant_words("Modern Family"); + assert!(!passes_relevance_filter( + &words, + "Family.Guy.S22E15.1080p.WEB.H264-SuccessfulCrab" + )); + assert!(!passes_relevance_filter( + &words, + "The Addams Family (2019) [WEBRip] [1080p]" + )); + } + + #[test] + fn relevance_filter_accepts_a_genuine_match() { + let words = significant_words("Modern Family"); + assert!(passes_relevance_filter( + &words, + "Modern.Family.S11E01.1080p.WEB-DL.DDP5.1.H.264" + )); + } + + #[test] + fn relevance_filter_lets_everything_through_for_titles_with_no_significant_words() { + // e.g. a title that's entirely short/common words after filtering + assert!(passes_relevance_filter(&[], "anything at all")); + } +} diff --git a/breadarrd/src/scoring/gate.rs b/breadarrd/src/scoring/gate.rs new file mode 100644 index 0000000..7e4a55f --- /dev/null +++ b/breadarrd/src/scoring/gate.rs @@ -0,0 +1,262 @@ +use crate::parser::ParsedRelease; + +use super::profile::QualityProfile; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RejectReason { + MissingSeederData, + BelowMinSeeders, + SizeOutOfRange, + GroupDenylisted, + NoEnglishAudio, + /// Below 1080p while a 1080p-or-better candidate exists for the same + /// target in this same search batch — rejected in favor of the better + /// one, not because low resolution is inherently disqualifying (a + /// release with no 1080p+ option anywhere is still accepted; see + /// `GateContext::better_resolution_available`). + LowResolutionAlternativeExists, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GateResult { + Accept, + Reject(RejectReason), +} + +pub struct GateContext { + pub seeders: Option, + pub size_bytes: Option, + /// Episode/movie runtime, when known — used to sanity-check size against + /// implied bitrate. Frequently unavailable (neither TVDB's nor TMDB's + /// episode-level data reliably carries this), so size checks fall back + /// to absolute per-resolution bounds when absent. + pub runtime_minutes: Option, + pub has_english_audio: bool, + /// Anime gets a carve-out on the English-audio gate: falls back to + /// Japanese-only audio when no English release exists at all. + pub is_anime: bool, + /// True when at least one other candidate in the same search batch + /// parsed to 1080p or better — the signal that makes a sub-1080p + /// candidate here a real downgrade rather than the only option. Always + /// `false` on the RSS feed-watch path (no batch of alternatives to + /// compare against there), so this gate never fires for that path. + pub better_resolution_available: bool, + /// A season/batch release covering many episodes in one torrent — its + /// total size is naturally a multiple of a single episode's, so the + /// per-episode size/bitrate sanity check would reject it as absurdly + /// oversized every time. There's no reliable episode count to scale + /// the bounds by before the torrent is actually inspected, so the size + /// gate is skipped entirely rather than guessed at. + pub is_season_pack: bool, +} + +pub fn evaluate(parsed: &ParsedRelease, ctx: &GateContext, profile: &QualityProfile) -> GateResult { + let Some(seeders) = ctx.seeders else { + return GateResult::Reject(RejectReason::MissingSeederData); + }; + if seeders < profile.min_seeders { + return GateResult::Reject(RejectReason::BelowMinSeeders); + } + + if !ctx.is_season_pack { + if let (Some(size), Some(resolution)) = (ctx.size_bytes, parsed.resolution) { + if !size_is_sane(size, resolution, ctx.runtime_minutes) { + return GateResult::Reject(RejectReason::SizeOutOfRange); + } + } + } + + if ctx.better_resolution_available && parsed.resolution.is_some_and(|r| r < 1080) { + return GateResult::Reject(RejectReason::LowResolutionAlternativeExists); + } + + if let Some(group) = &parsed.group { + if profile + .group_denylist + .iter() + .any(|g| g.eq_ignore_ascii_case(group)) + { + return GateResult::Reject(RejectReason::GroupDenylisted); + } + } + + if !ctx.has_english_audio && !ctx.is_anime { + return GateResult::Reject(RejectReason::NoEnglishAudio); + } + + GateResult::Accept +} + +fn size_is_sane(size_bytes: u64, resolution: u32, runtime_minutes: Option) -> bool { + let Some(runtime_minutes) = runtime_minutes.filter(|&r| r > 0) else { + return absolute_size_bounds(resolution).contains(&size_bytes); + }; + + let seconds = (runtime_minutes as u64) * 60; + let bits_per_second = (size_bytes * 8) / seconds; + let (min_bps, max_bps) = bitrate_bounds(resolution); + (min_bps..=max_bps).contains(&bits_per_second) +} + +fn bitrate_bounds(resolution: u32) -> (u64, u64) { + match resolution { + r if r >= 2160 => (2_000_000, 80_000_000), + r if r >= 1080 => (800_000, 40_000_000), + r if r >= 720 => (400_000, 20_000_000), + _ => (150_000, 10_000_000), + } +} + +fn absolute_size_bounds(resolution: u32) -> std::ops::RangeInclusive { + match resolution { + r if r >= 2160 => 200_000_000..=40_000_000_000, + r if r >= 1080 => 50_000_000..=15_000_000_000, + r if r >= 720 => 20_000_000..=8_000_000_000, + _ => 5_000_000..=4_000_000_000, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser; + + fn ctx() -> GateContext { + GateContext { + seeders: Some(50), + size_bytes: Some(400_000_000), + runtime_minutes: Some(24), + has_english_audio: true, + is_anime: false, + better_resolution_available: false, + is_season_pack: false, + } + } + + #[test] + fn accepts_a_healthy_english_release() { + let parsed = parser::parse("Some Show S01E01 1080p WEB-DL H264"); + assert_eq!( + evaluate(&parsed, &ctx(), &QualityProfile::default_tv()), + GateResult::Accept + ); + } + + #[test] + fn rejects_missing_seeder_data() { + let parsed = parser::parse("Some Show S01E01 1080p WEB-DL H264"); + let mut c = ctx(); + c.seeders = None; + assert_eq!( + evaluate(&parsed, &c, &QualityProfile::default_tv()), + GateResult::Reject(RejectReason::MissingSeederData) + ); + } + + #[test] + fn rejects_below_min_seeders() { + let parsed = parser::parse("Some Show S01E01 1080p WEB-DL H264"); + let mut c = ctx(); + c.seeders = Some(1); + assert_eq!( + evaluate(&parsed, &c, &QualityProfile::default_tv()), + GateResult::Reject(RejectReason::BelowMinSeeders) + ); + } + + #[test] + fn rejects_absurdly_small_file_for_claimed_resolution() { + let parsed = parser::parse("Some Movie 2024 2160p BluRay"); + let mut c = ctx(); + c.size_bytes = Some(1_000_000); // 1MB claiming to be a 4K release + c.runtime_minutes = None; + assert_eq!( + evaluate(&parsed, &c, &QualityProfile::default_movie()), + GateResult::Reject(RejectReason::SizeOutOfRange) + ); + } + + #[test] + fn rejects_denylisted_group() { + let parsed = parser::parse("[BadGroup] Some Show - 01 [1080p]"); + let mut c = ctx(); + c.is_anime = true; + let mut profile = QualityProfile::default_tv(); + profile.group_denylist.push("BadGroup".to_string()); + assert_eq!( + evaluate(&parsed, &c, &profile), + GateResult::Reject(RejectReason::GroupDenylisted) + ); + } + + #[test] + fn rejects_non_english_audio_for_non_anime() { + let parsed = parser::parse("Some Show S01E01 1080p WEB-DL H264"); + let mut c = ctx(); + c.has_english_audio = false; + c.is_anime = false; + assert_eq!( + evaluate(&parsed, &c, &QualityProfile::default_tv()), + GateResult::Reject(RejectReason::NoEnglishAudio) + ); + } + + #[test] + fn anime_without_english_audio_is_allowed_through() { + let parsed = parser::parse("[SubsPlease] Some Anime - 01 (1080p)"); + let mut c = ctx(); + c.has_english_audio = false; + c.is_anime = true; + assert_eq!( + evaluate(&parsed, &c, &QualityProfile::default_tv()), + GateResult::Accept + ); + } + + #[test] + fn rejects_below_1080p_when_a_better_alternative_exists() { + let parsed = parser::parse("Some Show S01E01 720p WEB-DL H264"); + let mut c = ctx(); + c.better_resolution_available = true; + assert_eq!( + evaluate(&parsed, &c, &QualityProfile::default_tv()), + GateResult::Reject(RejectReason::LowResolutionAlternativeExists) + ); + } + + #[test] + fn accepts_below_1080p_when_it_is_the_only_option() { + let parsed = parser::parse("Some Show S01E01 720p WEB-DL H264"); + let mut c = ctx(); + c.better_resolution_available = false; + assert_eq!( + evaluate(&parsed, &c, &QualityProfile::default_tv()), + GateResult::Accept + ); + } + + #[test] + fn season_pack_bypasses_the_size_sanity_check() { + let parsed = parser::parse("Some Show S01 1080p WEB-DL H264"); + let mut c = ctx(); + c.is_season_pack = true; + // A whole season's worth of episodes at once — would fail + // size_is_sane badly if evaluated as if it were one episode. + c.size_bytes = Some(400_000_000 * 12); + assert_eq!( + evaluate(&parsed, &c, &QualityProfile::default_tv()), + GateResult::Accept + ); + } + + #[test] + fn accepts_1080p_regardless_of_better_resolution_available() { + let parsed = parser::parse("Some Show S01E01 1080p WEB-DL H264"); + let mut c = ctx(); + c.better_resolution_available = true; + assert_eq!( + evaluate(&parsed, &c, &QualityProfile::default_tv()), + GateResult::Accept + ); + } +} diff --git a/breadarrd/src/scoring/mod.rs b/breadarrd/src/scoring/mod.rs new file mode 100644 index 0000000..ff9fa8b --- /dev/null +++ b/breadarrd/src/scoring/mod.rs @@ -0,0 +1,7 @@ +pub mod gate; +pub mod profile; +pub mod score; + +pub use gate::{evaluate as evaluate_gates, GateContext, GateResult, RejectReason}; +pub use profile::{ProfileKind, QualityProfile}; +pub use score::score; diff --git a/breadarrd/src/scoring/profile.rs b/breadarrd/src/scoring/profile.rs new file mode 100644 index 0000000..0c3be1a --- /dev/null +++ b/breadarrd/src/scoring/profile.rs @@ -0,0 +1,211 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProfileKind { + Movie, + Tv, +} + +#[derive(Debug, Clone)] +pub struct Weights { + pub seeder: f32, + /// Weighted heavily on purpose: resolution is the single most visible + /// quality axis, and without it in the score, same-episode releases at + /// 480p/720p/1080p (routine — SubsPlease and friends publish all three + /// of an episode within the same feed window) scored identically apart + /// from source/codec, letting a lower-resolution duplicate arrive later + /// and outscore (and at import time, overwrite) an already-grabbed + /// better one on nothing but a seeder-count edge. + pub resolution_tier: f32, + pub source_tier: f32, + pub codec_tier: f32, + pub bit_depth: f32, + pub container: f32, + pub group_allowlist: f32, + pub repack: f32, + /// HDR/Dolby Vision bonus. Always 0 on TV/anime profiles — only movie + /// profiles score this axis at all, per the product decision that HDR + /// only matters for movies. + pub hdr: f32, +} + +#[derive(Debug, Clone)] +pub struct QualityProfile { + pub kind: ProfileKind, + pub min_seeders: u32, + pub group_denylist: Vec, + pub group_allowlist: Vec, + pub weights: Weights, +} + +impl QualityProfile { + pub fn default_tv() -> Self { + Self { + kind: ProfileKind::Tv, + min_seeders: 3, + group_denylist: Vec::new(), + group_allowlist: Vec::new(), + weights: Weights { + seeder: 1.0, + resolution_tier: 3.0, + source_tier: 3.0, + codec_tier: 2.0, + bit_depth: 0.5, + container: 0.25, + group_allowlist: 1.0, + repack: 1.5, + hdr: 0.0, + }, + } + } + + pub fn default_movie() -> Self { + Self { + kind: ProfileKind::Movie, + min_seeders: 3, + group_denylist: Vec::new(), + group_allowlist: Vec::new(), + weights: Weights { + seeder: 1.0, + resolution_tier: 3.0, + source_tier: 3.0, + codec_tier: 2.0, + bit_depth: 0.5, + container: 0.25, + group_allowlist: 1.0, + repack: 1.5, + hdr: 2.0, + }, + } + } + + /// Builds the default profile for `kind`, then applies a user-supplied + /// JSON weights override on top of it — the `quality_profile.weights` + /// column's actual content, finally read instead of ignored. Every + /// field is individually optional, so e.g. `{"hdr": 5.0}` overrides + /// only the HDR bonus and leaves every other axis at its built-in + /// default, rather than requiring a full weights object to change one + /// number. Empty (`""`/`"{}"`), missing, or malformed JSON all + /// harmlessly fall back to exactly the hardcoded defaults — a bad + /// config value must never break scoring or gating. + pub fn with_weights_override(kind: ProfileKind, weights_json: &str) -> Self { + let mut profile = match kind { + ProfileKind::Movie => Self::default_movie(), + ProfileKind::Tv => Self::default_tv(), + }; + if weights_json.trim().is_empty() || weights_json.trim() == "{}" { + return profile; + } + match serde_json::from_str::(weights_json) { + Ok(o) => profile.weights = profile.weights.with_override(&o), + Err(e) => { + tracing::warn!( + error = %e, + weights_json, + "quality_profile.weights is not valid JSON; using built-in defaults" + ); + } + } + profile + } +} + +/// Every field optional so a partial JSON object only overrides the axes it +/// actually names — matches `serde_json::from_str`'s normal "missing field +/// stays at its `Default`" behavior for a struct made entirely of +/// `Option` fields. +#[derive(Debug, Clone, Default, Deserialize)] +struct WeightsOverride { + seeder: Option, + resolution_tier: Option, + source_tier: Option, + codec_tier: Option, + bit_depth: Option, + container: Option, + group_allowlist: Option, + repack: Option, + hdr: Option, +} + +impl Weights { + fn with_override(mut self, o: &WeightsOverride) -> Self { + if let Some(v) = o.seeder { + self.seeder = v; + } + if let Some(v) = o.resolution_tier { + self.resolution_tier = v; + } + if let Some(v) = o.source_tier { + self.source_tier = v; + } + if let Some(v) = o.codec_tier { + self.codec_tier = v; + } + if let Some(v) = o.bit_depth { + self.bit_depth = v; + } + if let Some(v) = o.container { + self.container = v; + } + if let Some(v) = o.group_allowlist { + self.group_allowlist = v; + } + if let Some(v) = o.repack { + self.repack = v; + } + if let Some(v) = o.hdr { + self.hdr = v; + } + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_or_absent_json_falls_back_to_exact_defaults() { + for json in ["", "{}", " ", " {} "] { + let profile = QualityProfile::with_weights_override(ProfileKind::Tv, json); + assert_eq!(profile.weights.resolution_tier, 3.0); + assert_eq!(profile.weights.hdr, 0.0); + } + } + + #[test] + fn partial_override_changes_only_the_named_axis() { + let profile = QualityProfile::with_weights_override(ProfileKind::Movie, r#"{"hdr": 5.0}"#); + assert_eq!(profile.weights.hdr, 5.0); + // Every other axis stays at the movie default, untouched. + assert_eq!(profile.weights.resolution_tier, 3.0); + assert_eq!(profile.weights.codec_tier, 2.0); + assert_eq!(profile.weights.seeder, 1.0); + } + + #[test] + fn full_override_replaces_every_axis() { + let json = r#"{"seeder": 9.0, "resolution_tier": 1.0, "source_tier": 1.0, + "codec_tier": 9.0, "bit_depth": 9.0, "container": 9.0, + "group_allowlist": 9.0, "repack": 9.0, "hdr": 9.0}"#; + let profile = QualityProfile::with_weights_override(ProfileKind::Tv, json); + assert_eq!(profile.weights.seeder, 9.0); + assert_eq!(profile.weights.resolution_tier, 1.0); + assert_eq!(profile.weights.codec_tier, 9.0); + assert_eq!(profile.weights.hdr, 9.0); + } + + #[test] + fn malformed_json_falls_back_to_defaults_instead_of_panicking() { + let profile = QualityProfile::with_weights_override(ProfileKind::Tv, "not json at all"); + assert_eq!(profile.weights.resolution_tier, 3.0); + } + + #[test] + fn movie_and_tv_still_get_different_hdr_defaults_with_no_override() { + let movie = QualityProfile::with_weights_override(ProfileKind::Movie, "{}"); + let tv = QualityProfile::with_weights_override(ProfileKind::Tv, "{}"); + assert_eq!(movie.weights.hdr, 2.0); + assert_eq!(tv.weights.hdr, 0.0); + } +} diff --git a/breadarrd/src/scoring/score.rs b/breadarrd/src/scoring/score.rs new file mode 100644 index 0000000..867b302 --- /dev/null +++ b/breadarrd/src/scoring/score.rs @@ -0,0 +1,165 @@ +use crate::parser::ParsedRelease; + +use super::profile::{ProfileKind, QualityProfile}; + +pub fn score(parsed: &ParsedRelease, seeders: u32, has_hdr: bool, profile: &QualityProfile) -> f32 { + let w = &profile.weights; + let mut total = 0.0; + + total += w.seeder * seeder_score(seeders); + total += w.resolution_tier * resolution_tier(parsed.resolution); + total += w.source_tier * parsed.source.map(|s| s as u8 as f32).unwrap_or(0.0); + total += w.codec_tier * parsed.codec.map(|c| c as u8 as f32).unwrap_or(0.0); + + if parsed.bit_depth == Some(10) { + total += w.bit_depth; + } + if parsed.container.as_deref() == Some("mkv") { + total += w.container; + } + if let Some(group) = &parsed.group { + if profile + .group_allowlist + .iter() + .any(|g| g.eq_ignore_ascii_case(group)) + { + total += w.group_allowlist; + } + } + if parsed.is_repack { + total += w.repack; + } + if has_hdr && profile.kind == ProfileKind::Movie { + total += w.hdr; + } + + total +} + +/// Log-scaled so 5 vs 50 seeders matters far more than 400 vs 4000. +fn seeder_score(seeders: u32) -> f32 { + ((seeders as f32) + 1.0).ln() +} + +/// An unparsed resolution scores the same as the bottom tier — no signal +/// either way, not a reason to reject, but not a reason to prefer it either. +fn resolution_tier(resolution: Option) -> f32 { + match resolution { + Some(r) if r >= 2160 => 3.0, + Some(r) if r >= 1080 => 2.0, + Some(r) if r >= 720 => 1.0, + _ => 0.0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser; + + #[test] + fn av1_outscores_hevc_outscores_h264_all_else_equal() { + let profile = QualityProfile::default_tv(); + let av1 = parser::parse("Show S01E01 1080p WEB-DL AV1"); + let hevc = parser::parse("Show S01E01 1080p WEB-DL HEVC"); + let h264 = parser::parse("Show S01E01 1080p WEB-DL H264"); + + let s_av1 = score(&av1, 50, false, &profile); + let s_hevc = score(&hevc, 50, false, &profile); + let s_h264 = score(&h264, 50, false, &profile); + + assert!(s_av1 > s_hevc); + assert!(s_hevc > s_h264); + } + + #[test] + fn remux_outscores_webrip_all_else_equal() { + let profile = QualityProfile::default_movie(); + let remux = parser::parse("Movie 2024 2160p Remux H264"); + let webrip = parser::parse("Movie 2024 2160p WEBRip H264"); + assert!(score(&remux, 50, false, &profile) > score(&webrip, 50, false, &profile)); + } + + #[test] + fn more_seeders_scores_higher_but_with_diminishing_returns() { + let profile = QualityProfile::default_tv(); + let parsed = parser::parse("Show S01E01 1080p WEB-DL H264"); + + let low = score(&parsed, 5, false, &profile); + let mid = score(&parsed, 50, false, &profile); + let high = score(&parsed, 4000, false, &profile); + + assert!(mid > low); + assert!(high > mid); + // Diminishing returns on a log scale: the *same absolute* jump of + // +45 seeders matters far more starting from 5 than starting from + // 4000 (equal-ratio jumps like 5->50 vs 400->4000 are a different, + // roughly-equal-delta comparison — not what's being tested here). + let low_plus_45 = score(&parsed, 50, false, &profile) - low; + let high_plus_45 = score(&parsed, 4045, false, &profile) - high; + assert!(low_plus_45 > high_plus_45); + } + + #[test] + fn hdr_bonus_applies_to_movies_not_tv() { + let parsed = parser::parse("Title 2024 2160p BluRay H264"); + let movie_profile = QualityProfile::default_movie(); + let tv_profile = QualityProfile::default_tv(); + + let movie_with_hdr = score(&parsed, 50, true, &movie_profile); + let movie_without_hdr = score(&parsed, 50, false, &movie_profile); + assert!(movie_with_hdr > movie_without_hdr); + + let tv_with_hdr = score(&parsed, 50, true, &tv_profile); + let tv_without_hdr = score(&parsed, 50, false, &tv_profile); + assert_eq!( + tv_with_hdr, tv_without_hdr, + "HDR must not affect TV/anime scoring" + ); + } + + #[test] + fn ten_bit_and_mkv_and_repack_each_add_a_bonus() { + let profile = QualityProfile::default_tv(); + let base = parser::parse("Show S01E01 1080p WEB-DL H264"); + let ten_bit = parser::parse("Show S01E01 1080p WEB-DL H264 10bit"); + let mkv = parser::parse("Show S01E01 1080p WEB-DL H264.mkv"); + let repack = parser::parse("Show S01E01 1080p WEB-DL H264 REPACK"); + + let base_score = score(&base, 50, false, &profile); + assert!(score(&ten_bit, 50, false, &profile) > base_score); + assert!(score(&mkv, 50, false, &profile) > base_score); + assert!(score(&repack, 50, false, &profile) > base_score); + } + + #[test] + fn higher_resolution_outscores_lower_all_else_equal() { + // The real scenario this guards: SubsPlease et al. publish 480p, + // 720p, and 1080p of the same episode within the same feed + // window — without a resolution term, these scored identically + // apart from seeders, letting a later lower-res duplicate outscore + // (and overwrite at import) an already-grabbed 1080p file. + let profile = QualityProfile::default_tv(); + let p1080 = parser::parse("[SubsPlease] Show - 01 (1080p) [ABCD1234].mkv"); + let p720 = parser::parse("[SubsPlease] Show - 01 (720p) [ABCD1234].mkv"); + let p480 = parser::parse("[SubsPlease] Show - 01 (480p) [ABCD1234].mkv"); + + let s1080 = score(&p1080, 50, false, &profile); + let s720 = score(&p720, 50, false, &profile); + let s480 = score(&p480, 50, false, &profile); + + assert!(s1080 > s720); + assert!(s720 > s480); + } + + #[test] + fn allowlisted_group_scores_higher_than_unlisted() { + let mut profile = QualityProfile::default_tv(); + profile.group_allowlist.push("SubsPlease".to_string()); + + let allowlisted = parser::parse("[SubsPlease] Show - 01 [1080p]"); + let unlisted = parser::parse("[RandomGroup] Show - 01 [1080p]"); + + assert!(score(&allowlisted, 50, false, &profile) > score(&unlisted, 50, false, &profile)); + } +} diff --git a/breadarrd/src/sources/mod.rs b/breadarrd/src/sources/mod.rs new file mode 100644 index 0000000..f6676ca --- /dev/null +++ b/breadarrd/src/sources/mod.rs @@ -0,0 +1,83 @@ +pub mod rss; +pub mod scrape; +pub mod tpb; + +use anyhow::Result; +use async_trait::async_trait; + +#[derive(Debug, Clone, PartialEq)] +pub struct RawReleaseItem { + pub title: String, + /// Magnet URI or direct .torrent download URL — qBittorrent's add-by-URL + /// endpoint accepts either identically. + pub link: String, + pub guid: String, + pub size_bytes: Option, + pub seeders: Option, + pub leechers: Option, +} + +#[async_trait] +pub trait ReleaseSource { + /// Fetch current candidate releases. `query` is used by search-driven + /// sources (e.g. a scraped search page); feed-based sources ignore it + /// and return everything currently in the feed. + async fn fetch(&self, query: Option<&str>) -> Result>; +} + +pub(crate) fn urlencode(s: &str) -> String { + s.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~') { + c.to_string() + } else if c == ' ' { + "%20".to_string() + } else { + let mut buf = [0u8; 4]; + c.encode_utf8(&mut buf) + .bytes() + .map(|b| format!("%{b:02X}")) + .collect() + } + }) + .collect() +} + +pub(crate) fn parse_human_size(s: &str) -> Option { + let s = s.trim(); + let (num_part, unit) = s.split_once(' ')?; + let num: f64 = num_part.parse().ok()?; + let mult = match unit { + "B" => 1.0, + // 1337x labels these "KB/MB/GB" (decimal-looking) but the numbers + // are actually binary (1024-based), matching every other torrent + // site's convention — treat them the same as the KiB/MiB/GiB nyaa + // uses. + "KiB" | "KB" => 1024.0, + "MiB" | "MB" => 1024.0 * 1024.0, + "GiB" | "GB" => 1024.0 * 1024.0 * 1024.0, + "TiB" | "TB" => 1024.0_f64.powi(4), + _ => return None, + }; + Some((num * mult) as u64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_gib() { + assert_eq!(parse_human_size("38.1 GiB"), Some(40909563494)); + } + + #[test] + fn parses_mib() { + assert_eq!(parse_human_size("356.5 MiB"), Some(373817344)); + } + + #[test] + fn rejects_unknown_unit() { + assert_eq!(parse_human_size("5 XiB"), None); + } +} diff --git a/breadarrd/src/sources/rss.rs b/breadarrd/src/sources/rss.rs new file mode 100644 index 0000000..34adf96 --- /dev/null +++ b/breadarrd/src/sources/rss.rs @@ -0,0 +1,196 @@ +use anyhow::Result; +use async_trait::async_trait; +use quick_xml::escape::unescape; +use quick_xml::events::Event; +use quick_xml::reader::Reader; + +use super::{parse_human_size, urlencode, RawReleaseItem, ReleaseSource}; + +/// RSS source for nyaa.si-style feeds, whose `nyaa:seeders`/`nyaa:leechers`/ +/// `nyaa:size` custom-namespaced fields carry the seeder data this project +/// needs — generic feed libraries (e.g. feed-rs) don't surface arbitrary +/// vendor namespaces, so this parses the raw XML directly. +pub struct RssSource { + feed_url: String, + client: reqwest::Client, +} + +impl RssSource { + pub fn new(feed_url: impl Into) -> Self { + Self { + feed_url: feed_url.into(), + // The background loop holds the DB mutex across this fetch — no + // total timeout (reqwest's default) means a stalled connection + // hangs the whole daemon, not just this one request. + client: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("reqwest client build"), + } + } +} + +#[async_trait] +impl ReleaseSource for RssSource { + async fn fetch(&self, query: Option<&str>) -> Result> { + let url = build_search_url(&self.feed_url, query); + let bytes = self.client.get(&url).send().await?.bytes().await?; + parse_nyaa_rss(&bytes) + } +} + +/// nyaa's search *is* its RSS feed with a `q` param appended — same +/// endpoint, same custom-namespace fields, so the parser needs no changes +/// at all for search-driven use. +fn build_search_url(feed_url: &str, query: Option<&str>) -> String { + match query { + Some(q) => { + let sep = if feed_url.contains('?') { '&' } else { '?' }; + format!("{feed_url}{sep}q={}", urlencode(q)) + } + None => feed_url.to_string(), + } +} + +fn parse_nyaa_rss(bytes: &[u8]) -> Result> { + let mut reader = Reader::from_reader(bytes); + reader.config_mut().trim_text(true); + + let mut items = Vec::new(); + let mut buf = Vec::new(); + + let mut in_item = false; + let mut cur_tag = String::new(); + let mut title = None; + let mut link = None; + let mut guid = None; + let mut seeders = None; + let mut leechers = None; + let mut size_bytes = None; + + loop { + match reader.read_event_into(&mut buf)? { + Event::Eof => break, + Event::Start(e) => { + let name = String::from_utf8_lossy(e.name().as_ref()).into_owned(); + if name == "item" { + in_item = true; + title = None; + link = None; + guid = None; + seeders = None; + leechers = None; + size_bytes = None; + } + cur_tag = name; + } + Event::Text(t) if in_item => { + let raw = t.decode()?; + let text = unescape(&raw)?.into_owned(); + match cur_tag.as_str() { + "title" => title = Some(text), + "link" => link = Some(text), + "guid" => guid = Some(text), + "nyaa:seeders" => seeders = text.parse().ok(), + "nyaa:leechers" => leechers = text.parse().ok(), + "nyaa:size" => size_bytes = parse_human_size(&text), + _ => {} + } + } + Event::End(e) => { + let name = String::from_utf8_lossy(e.name().as_ref()).into_owned(); + if name == "item" { + if let (Some(title), Some(link), Some(guid)) = + (title.take(), link.take(), guid.take()) + { + items.push(RawReleaseItem { + title, + link, + guid, + size_bytes, + seeders, + leechers, + }); + } + in_item = false; + } + } + _ => {} + } + buf.clear(); + } + + Ok(items) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#" + + +Nyaa - Home - Torrent File RSS + + [Group] Some Show - 05 [1080p] + https://nyaa.si/download/2130903.torrent + https://nyaa.si/view/2130903 + Sat, 11 Jul 2026 08:51:04 -0000 + 12 + 3 + 356.5 MiB + + +"#; + + #[test] + fn parses_nyaa_item_with_custom_fields() { + let items = parse_nyaa_rss(SAMPLE.as_bytes()).unwrap(); + assert_eq!(items.len(), 1); + let item = &items[0]; + assert_eq!(item.title, "[Group] Some Show - 05 [1080p]"); + assert_eq!(item.link, "https://nyaa.si/download/2130903.torrent"); + assert_eq!(item.guid, "https://nyaa.si/view/2130903"); + assert_eq!(item.seeders, Some(12)); + assert_eq!(item.leechers, Some(3)); + assert_eq!(item.size_bytes, Some(373817344)); + } + + #[test] + fn build_search_url_appends_query_param() { + assert_eq!( + build_search_url("https://nyaa.si/?page=rss&c=1_2", Some("Some Movie 2016")), + "https://nyaa.si/?page=rss&c=1_2&q=Some%20Movie%202016" + ); + } + + #[test] + fn build_search_url_handles_a_feed_url_with_no_existing_query_string() { + assert_eq!( + build_search_url("https://nyaa.si/rss", Some("Some Movie")), + "https://nyaa.si/rss?q=Some%20Movie" + ); + } + + #[test] + fn build_search_url_is_unchanged_without_a_query() { + assert_eq!( + build_search_url("https://nyaa.si/?page=rss", None), + "https://nyaa.si/?page=rss" + ); + } + + /// Hits the real nyaa.si feed — not run by default. `cargo test -- --ignored` + /// to sanity-check the parser against live markup if nyaa changes their format. + #[tokio::test] + #[ignore] + async fn parses_live_nyaa_feed() { + let source = RssSource::new("https://nyaa.si/?page=rss"); + let items = source.fetch(None).await.unwrap(); + assert!( + !items.is_empty(), + "expected at least one item from live feed" + ); + assert!(items.iter().any(|i| i.seeders.is_some())); + } +} diff --git a/breadarrd/src/sources/scrape.rs b/breadarrd/src/sources/scrape.rs new file mode 100644 index 0000000..9171198 --- /dev/null +++ b/breadarrd/src/sources/scrape.rs @@ -0,0 +1,549 @@ +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use anyhow::{bail, Context, Result}; +use async_trait::async_trait; +use scraper::{Html, Selector}; + +use super::{parse_human_size, urlencode, RawReleaseItem, ReleaseSource}; + +const BASE_COOLDOWN_SECS: u64 = 5 * 60; +const MAX_COOLDOWN: Duration = Duration::from_secs(6 * 60 * 60); +const RATE_LIMIT_MIN_COOLDOWN: Duration = Duration::from_secs(30 * 60); +const RATE_LIMIT_MAX_RETRY_AFTER: Duration = Duration::from_secs(60 * 60); +/// Hard cap on the tracked failure streak — well past the point where +/// `BASE_COOLDOWN_SECS * 4^(streak-1)` already exceeds `MAX_COOLDOWN`, it +/// exists purely so the exponent can never grow large enough to overflow. +const MAX_FAILURE_STREAK: u32 = 10; + +/// 1337x's main domain (1337x.to) bans IPs at the Cloudflare WAF level +/// after bursts of automated traffic — a network-level block that no +/// amount of browser-fingerprint evasion gets around. Its community +/// mirrors run on separate domains/Cloudflare zones, so a ban on one +/// doesn't carry over. Requests are round-robined across mirrors (not just +/// tried in a fixed fallback order) so no single domain absorbs the bulk of +/// traffic, and a mirror that fails is demoted into a cooldown rather than +/// re-probed on the very next search. +pub struct ScrapeSource { + mirrors: Vec, + client: reqwest::Client, + ring: Mutex, +} + +struct MirrorRing { + next: usize, + cooldown_until: Vec>, + failure_streak: Vec, +} + +impl MirrorRing { + fn new(count: usize) -> Self { + Self { + next: 0, + cooldown_until: vec![None; count], + failure_streak: vec![0; count], + } + } +} + +/// Distinguishes *why* a mirror attempt failed, since the right cooldown +/// differs: a WAF-issued rate-limit/block response means "this domain is +/// now watching you" and gets a firm minimum cooldown regardless of streak, +/// while a timeout or a challenge page gets the milder exponential ladder. +#[derive(Debug)] +enum MirrorError { + /// No results table found at all — most likely a Cloudflare challenge + /// page served with an HTTP 200 (so `error_for_status` wouldn't catch + /// it), or the mirror's HTML layout has drifted. + Challenge, + /// 403/429/503 — an explicit throttle/block signal from the WAF, not a + /// generic network failure. + RateLimited { + retry_after_secs: Option, + }, + Other(anyhow::Error), +} + +impl std::fmt::Display for MirrorError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MirrorError::Challenge => write!(f, "no results table found (challenge page?)"), + MirrorError::RateLimited { retry_after_secs } => { + write!(f, "rate limited (retry_after={retry_after_secs:?})") + } + MirrorError::Other(e) => write!(f, "{e}"), + } + } +} + +/// Returned by [`ScrapeSource::fetch`] when every mirror is either on +/// cooldown or failed this attempt — a distinct type (rather than a plain +/// string error) so callers can `downcast_ref` to trigger cycle-level +/// backoff without string-matching an error message. +#[derive(Debug)] +pub struct AllMirrorsFailed; + +impl std::fmt::Display for AllMirrorsFailed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "all 1337x mirrors failed or are in cooldown") + } +} + +impl std::error::Error for AllMirrorsFailed {} + +impl ScrapeSource { + pub fn new(mirrors: Vec) -> Self { + let count = mirrors.len(); + Self { + mirrors, + client: reqwest::Client::builder() + .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") + .timeout(Duration::from_secs(30)) + // Cloudflare's cf_clearance cookie is per-zone (per mirror + // domain) — holding it means a mirror that challenged once + // can pass on subsequent requests instead of re-challenging + // every single time. + .cookie_store(true) + .build() + .expect("reqwest client build"), + ring: Mutex::new(MirrorRing::new(count)), + } + } + + /// Mirror indices to try, starting from the round-robin cursor and + /// wrapping, skipping any still in cooldown. Advances the cursor + /// unconditionally (not just on success) so consecutive searches keep + /// moving through the ring instead of retrying the same start point. + fn candidate_order(&self) -> Vec { + let mut ring = self.ring.lock().expect("mirror ring poisoned"); + let start = ring.next; + if !self.mirrors.is_empty() { + ring.next = (ring.next + 1) % self.mirrors.len(); + } + compute_candidate_order( + self.mirrors.len(), + start, + &ring.cooldown_until, + Instant::now(), + ) + } + + fn record_success(&self, idx: usize) { + let mut ring = self.ring.lock().expect("mirror ring poisoned"); + ring.failure_streak[idx] = 0; + } + + fn demote(&self, idx: usize, err: &MirrorError) { + let mut ring = self.ring.lock().expect("mirror ring poisoned"); + let cooldown = match err { + MirrorError::RateLimited { retry_after_secs } => { + // A rate-limit response isn't generic flakiness — don't let + // it ratchet the exponential streak, just apply a firm + // minimum (or the server's own Retry-After, capped). + let retry_after = retry_after_secs + .map(|s| Duration::from_secs(s).min(RATE_LIMIT_MAX_RETRY_AFTER)); + retry_after + .unwrap_or(RATE_LIMIT_MIN_COOLDOWN) + .max(RATE_LIMIT_MIN_COOLDOWN) + } + MirrorError::Challenge | MirrorError::Other(_) => { + let streak = (ring.failure_streak[idx] + 1).min(MAX_FAILURE_STREAK); + ring.failure_streak[idx] = streak; + let secs = BASE_COOLDOWN_SECS.saturating_mul(4u64.saturating_pow(streak - 1)); + Duration::from_secs(secs).min(MAX_COOLDOWN) + } + }; + ring.cooldown_until[idx] = Some(Instant::now() + cooldown); + } + + async fn search_mirror( + &self, + mirror: &str, + query: &str, + ) -> std::result::Result, MirrorError> { + let url = format!( + "{}/search/{}/1/", + mirror.trim_end_matches('/'), + urlencode(query) + ); + let resp = self.client.get(&url).send().await.map_err(|e| { + MirrorError::Other(anyhow::Error::new(e).context(format!("request to {url} failed"))) + })?; + + let status = resp.status(); + if matches!(status.as_u16(), 403 | 429 | 503) { + let retry_after_secs = resp + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()); + return Err(MirrorError::RateLimited { retry_after_secs }); + } + let resp = resp.error_for_status().map_err(|e| { + MirrorError::Other( + anyhow::Error::new(e).context(format!("{url} returned an error status")), + ) + })?; + let body = resp.text().await.map_err(|e| { + MirrorError::Other(anyhow::Error::new(e).context("failed to read response body")) + })?; + parse_search_results(&body, mirror).ok_or(MirrorError::Challenge) + } +} + +/// Mirror indices to try, in round-robin order starting at `start`, +/// excluding any whose cooldown hasn't elapsed yet. Pure and separately +/// testable from the ring's locking/mutation. +fn compute_candidate_order( + len: usize, + start: usize, + cooldown_until: &[Option], + now: Instant, +) -> Vec { + if len == 0 { + return Vec::new(); + } + (0..len) + .map(|offset| (start + offset) % len) + .filter(|&i| cooldown_until[i].is_none_or(|until| now >= until)) + .collect() +} + +/// True if `link` is already directly usable by qBittorrent's add-by-URL +/// endpoint (a magnet URI or a direct .torrent download link) — false for +/// a detail-page URL that needs resolving first (what search results give +/// us, since the results table doesn't carry the magnet directly). +pub fn needs_resolution(link: &str) -> bool { + !(link.starts_with("magnet:") || link.ends_with(".torrent")) +} + +/// The search results table only links to a torrent's detail page, not its +/// magnet URI directly — fetches that page and extracts the magnet link. +/// A free function (not tied to a `ScrapeSource`/mirror list) because the +/// detail URL captured at search time already has the right mirror domain +/// baked in, and this needs to be callable from the grab path generically +/// (including review-queue approval, which only has a stored link, not a +/// `ScrapeSource` instance) — called only for the one candidate actually +/// being grabbed, not for every search result, to keep request volume low. +pub async fn resolve_magnet(client: &reqwest::Client, detail_url: &str) -> Result { + let resp = client + .get(detail_url) + .send() + .await + .with_context(|| format!("request to {detail_url} failed"))? + .error_for_status() + .with_context(|| format!("{detail_url} returned an error status"))?; + let body = resp.text().await.context("failed to read response body")?; + extract_magnet(&body).with_context(|| format!("no magnet link found on {detail_url}")) +} + +#[async_trait] +impl ReleaseSource for ScrapeSource { + async fn fetch(&self, query: Option<&str>) -> Result> { + let Some(query) = query else { + bail!( + "ScrapeSource requires a search query (this is a search-driven source, not a feed)" + ); + }; + if self.mirrors.is_empty() { + bail!("no 1337x mirrors configured"); + } + + for idx in self.candidate_order() { + let mirror = &self.mirrors[idx]; + match self.search_mirror(mirror, query).await { + Ok(items) => { + self.record_success(idx); + return Ok(items); + } + Err(e) => { + tracing::warn!(mirror, error = %e, "1337x mirror failed, trying next"); + self.demote(idx, &e); + } + } + } + Err(anyhow::Error::new(AllMirrorsFailed)) + } +} + +/// Pulls the numeric torrent ID out of a 1337x listing/detail href of the +/// shape `/torrent///` (relative) or `https:///torrent/ +/// //` (absolute) — the one part of the URL that's identical +/// across every mirror, unlike the domain. `None` if the href doesn't +/// contain `/torrent/` at all (an unexpected HTML shape), in which case +/// the caller falls back to the full URL rather than losing the item. +fn extract_torrent_id(href: &str) -> Option<&str> { + href.split("/torrent/") + .nth(1)? + .split('/') + .next() + .filter(|s| !s.is_empty()) +} + +/// `None` when the results table itself is missing from the document — +/// most likely a Cloudflare challenge page served with a 200 status (so it +/// never trips `error_for_status`), or the mirror's HTML layout drifted. +/// `Some(vec![])` is a genuine, trustworthy "no results" — those two cases +/// must not be conflated, since the former should demote the mirror and +/// the latter should not. +fn parse_search_results(html: &str, mirror: &str) -> Option> { + let doc = Html::parse_document(html); + let table_sel = Selector::parse("table.table-list").unwrap(); + doc.select(&table_sel).next()?; + + let row_sel = Selector::parse("table.table-list tbody tr").unwrap(); + let name_link_sel = Selector::parse("td.coll-1.name a:not(.icon)").unwrap(); + let seeds_sel = Selector::parse("td.coll-2").unwrap(); + let leeches_sel = Selector::parse("td.coll-3").unwrap(); + let size_sel = Selector::parse("td.coll-4").unwrap(); + + let mirror = mirror.trim_end_matches('/'); + let mut items = Vec::new(); + + for row in doc.select(&row_sel) { + let Some(link_el) = row.select(&name_link_sel).next() else { + continue; + }; + let Some(href) = link_el.value().attr("href") else { + continue; + }; + let title: String = link_el.text().collect(); + let title = title.trim().to_string(); + if title.is_empty() { + continue; + } + let detail_url = if href.starts_with("http") { + href.to_string() + } else { + format!("{mirror}{href}") + }; + // The dedup key must not depend on which mirror answered: the same + // physical torrent's `href` path (`/torrent///`) is + // identical across every mirror, but `detail_url` bakes in + // whichever mirror happened to serve this particular search — with + // ~10 mirrors round-robined for ban resilience, using the full URL + // as `guid` meant the same release looked "new" up to 10 times + // over, defeating `is_seen`/`mark_seen` dedup entirely and flooding + // the review queue with duplicates of the same low-confidence + // match (verified live: one title queued 13 times). The numeric + // torrent ID is the stable, mirror-invariant identity instead. + let guid = extract_torrent_id(href) + .map(|id| format!("1337x:{id}")) + .unwrap_or_else(|| detail_url.clone()); + + let seeders = row + .select(&seeds_sel) + .next() + .and_then(|e| e.text().collect::().trim().parse().ok()); + let leechers = row + .select(&leeches_sel) + .next() + .and_then(|e| e.text().collect::().trim().parse().ok()); + let size_bytes = row + .select(&size_sel) + .next() + .and_then(|e| parse_human_size(e.text().collect::().trim())); + + items.push(RawReleaseItem { + title, + // Not directly grabbable yet — a detail-page URL, resolved to + // a real magnet link via `resolve_magnet` right before the + // winning candidate is actually sent to qBittorrent. + link: detail_url, + guid, + size_bytes, + seeders, + leechers, + }); + } + + Some(items) +} + +fn extract_magnet(html: &str) -> Option { + let doc = Html::parse_document(html); + let sel = Selector::parse(r#"a[href^="magnet:"]"#).unwrap(); + doc.select(&sel) + .next() + .and_then(|e| e.value().attr("href")) + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_ROW: &str = r#" + + + + + + + + + + + + +
nameseletimesizeuploader
The Big Bang Theory S12E01 720p HDTV x264-KILLERS [eztv]39781443Sep. 25th '18559 MBEZTVag
"#; + + #[test] + fn parses_a_real_captured_result_row() { + let items = parse_search_results(SAMPLE_ROW, "https://13377x.info").unwrap(); + assert_eq!(items.len(), 1); + let item = &items[0]; + assert_eq!( + item.title, + "The Big Bang Theory S12E01 720p HDTV x264-KILLERS [eztv]" + ); + assert_eq!( + item.link, + "https://13377x.info/torrent/3250239/The-Big-Bang-Theory-S12E01-720p-HDTV-x264-KILLERS-eztv/" + ); + assert_eq!(item.guid, "1337x:3250239"); + assert_eq!(item.seeders, Some(3978)); + assert_eq!(item.leechers, Some(1443)); + assert_eq!(item.size_bytes, Some(586153984)); + } + + #[test] + fn guid_is_identical_across_different_mirrors_for_the_same_torrent() { + let a = parse_search_results(SAMPLE_ROW, "https://13377x.info").unwrap(); + let b = parse_search_results(SAMPLE_ROW, "https://1337x.maskbay.info").unwrap(); + // The `link` legitimately differs (it's used to actually fetch from + // whichever mirror answered this search) but the dedup `guid` must + // not, or mirror rotation defeats `is_seen`/`mark_seen` entirely — + // this was a real bug that flooded the review queue with up to 13 + // duplicate entries for the same release. + assert_ne!(a[0].link, b[0].link); + assert_eq!(a[0].guid, b[0].guid); + } + + #[test] + fn extract_torrent_id_handles_relative_and_absolute_hrefs() { + assert_eq!( + extract_torrent_id("/torrent/3602010/Instant-Family-2018/"), + Some("3602010") + ); + assert_eq!( + extract_torrent_id("https://13377x.info/torrent/3602010/Instant-Family-2018/"), + Some("3602010") + ); + } + + #[test] + fn extract_torrent_id_is_none_for_an_unexpected_shape() { + assert_eq!(extract_torrent_id("/sub/tv/HD/1/"), None); + } + + #[test] + fn returns_none_when_results_table_is_absent() { + let challenge_page = "

Just a moment...

"; + assert_eq!( + parse_search_results(challenge_page, "https://13377x.info"), + None + ); + } + + #[test] + fn returns_empty_vec_for_a_genuine_no_results_page() { + let empty_results = r#" + + +
name
"#; + assert_eq!( + parse_search_results(empty_results, "https://13377x.info"), + Some(vec![]) + ); + } + + #[test] + fn extracts_magnet_from_detail_page() { + let html = r#"Magnet Download"#; + assert_eq!( + extract_magnet(html).as_deref(), + Some("magnet:?xt=urn:btih:F30F455DC4C64A38E18C79C853B2A80B417C2343&dn=test") + ); + } + + #[test] + fn urlencode_handles_spaces_and_unicode() { + assert_eq!(urlencode("Big Buck Bunny"), "Big%20Buck%20Bunny"); + } + + #[test] + fn candidate_order_round_robins_from_start() { + let cooldowns = vec![None, None, None, None]; + let order = compute_candidate_order(4, 2, &cooldowns, Instant::now()); + assert_eq!(order, vec![2, 3, 0, 1]); + } + + #[test] + fn candidate_order_skips_mirrors_still_in_cooldown() { + let now = Instant::now(); + let cooldowns = vec![ + None, + Some(now + Duration::from_secs(600)), // still cooling down + None, + Some(now - Duration::from_secs(1)), // cooldown already elapsed + ]; + let order = compute_candidate_order(4, 0, &cooldowns, now); + assert_eq!(order, vec![0, 2, 3]); + } + + #[test] + fn candidate_order_empty_when_every_mirror_cooling_down() { + let now = Instant::now(); + let cooldowns = vec![Some(now + Duration::from_secs(60)); 3]; + let order = compute_candidate_order(3, 0, &cooldowns, now); + assert!(order.is_empty()); + } + + #[test] + fn demote_applies_a_firm_minimum_cooldown_for_rate_limiting() { + let source = ScrapeSource::new(vec!["https://a".into(), "https://b".into()]); + source.demote( + 0, + &MirrorError::RateLimited { + retry_after_secs: None, + }, + ); + let ring = source.ring.lock().unwrap(); + let until = ring.cooldown_until[0].expect("should be cooling down"); + assert!(until >= Instant::now() + Duration::from_secs(29 * 60)); + } + + #[test] + fn demote_caps_rate_limit_retry_after_at_one_hour() { + let source = ScrapeSource::new(vec!["https://a".into()]); + source.demote( + 0, + &MirrorError::RateLimited { + retry_after_secs: Some(999_999), + }, + ); + let ring = source.ring.lock().unwrap(); + let until = ring.cooldown_until[0].expect("should be cooling down"); + assert!(until <= Instant::now() + RATE_LIMIT_MAX_RETRY_AFTER + Duration::from_secs(5)); + } + + #[test] + fn demote_escalates_generic_failures_exponentially() { + let source = ScrapeSource::new(vec!["https://a".into()]); + source.demote(0, &MirrorError::Challenge); + let first = source.ring.lock().unwrap().cooldown_until[0].unwrap(); + source.demote(0, &MirrorError::Challenge); + let second = source.ring.lock().unwrap().cooldown_until[0].unwrap(); + assert!(second > first); + } + + #[test] + fn record_success_resets_failure_streak() { + let source = ScrapeSource::new(vec!["https://a".into()]); + source.demote(0, &MirrorError::Challenge); + assert_eq!(source.ring.lock().unwrap().failure_streak[0], 1); + source.record_success(0); + assert_eq!(source.ring.lock().unwrap().failure_streak[0], 0); + } +} diff --git a/breadarrd/src/sources/tpb.rs b/breadarrd/src/sources/tpb.rs new file mode 100644 index 0000000..b6730d6 --- /dev/null +++ b/breadarrd/src/sources/tpb.rs @@ -0,0 +1,132 @@ +use anyhow::{Context, Result}; +use async_trait::async_trait; +use serde::Deserialize; + +use super::{urlencode, RawReleaseItem, ReleaseSource}; + +/// A community-run JSON API mirror of The Pirate Bay's search — unlike +/// 1337x, this is a genuine machine-readable API (not HTML scraping), and +/// unlike 1337x's HTML results table, `info_hash` is enough to build a +/// magnet directly: no second detail-page fetch needed to resolve a link +/// before grabbing. Also proved dramatically more precise in practice — +/// its search actually ranks by relevance, where 1337x's is a pure +/// seeder-count sort that buries genuine matches for common-word titles +/// under unrelated, much-more-seeded content. +pub struct TpbSource { + api_url: String, + client: reqwest::Client, +} + +#[derive(Deserialize)] +struct TpbResult { + id: String, + name: String, + info_hash: String, + seeders: String, + leechers: String, + size: String, +} + +const TRACKERS: &[&str] = &[ + "udp://tracker.opentrackr.org:1337/announce", + "udp://open.stealth.si:80/announce", + "udp://tracker.torrent.eu.org:451/announce", + "udp://tracker.openbittorrent.com:6969/announce", + "udp://exodus.desync.com:6969/announce", +]; + +fn build_magnet(info_hash: &str, name: &str) -> String { + let mut magnet = format!("magnet:?xt=urn:btih:{info_hash}&dn={}", urlencode(name)); + for t in TRACKERS { + magnet.push_str("&tr="); + magnet.push_str(&urlencode(t)); + } + magnet +} + +impl TpbSource { + pub fn new(api_url: impl Into) -> Self { + Self { + api_url: api_url.into(), + // No total timeout is reqwest's default — the background loop + // holds the DB mutex across this fetch, so a stalled connection + // would hang the whole daemon. + client: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("reqwest client build"), + } + } +} + +#[async_trait] +impl ReleaseSource for TpbSource { + async fn fetch(&self, query: Option<&str>) -> Result> { + let Some(query) = query else { + anyhow::bail!( + "TpbSource requires a search query (this is a search-driven source, not a feed)" + ); + }; + let url = format!("{}?q={}", self.api_url, urlencode(query)); + let results: Vec = self + .client + .get(&url) + .send() + .await + .with_context(|| format!("request to {url} failed"))? + .error_for_status() + .with_context(|| format!("{url} returned an error status"))? + .json() + .await + .context("failed to parse apibay response as JSON")?; + + Ok(results + .into_iter() + // A query with no matches returns a single sentinel row + // (id="0", an all-zero info_hash) rather than an empty array — + // has to be filtered out explicitly or it'd be treated as one + // real (and completely bogus) result. + .filter(|r| r.id != "0" && !r.info_hash.chars().all(|c| c == '0')) + .map(|r| RawReleaseItem { + title: r.name.clone(), + link: build_magnet(&r.info_hash, &r.name), + guid: r.info_hash, + size_bytes: r.size.parse().ok(), + seeders: r.seeders.parse().ok(), + leechers: r.leechers.parse().ok(), + }) + .collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_magnet_includes_hash_name_and_trackers() { + let magnet = build_magnet("ABC123", "Some.Show.S01E01"); + assert!(magnet.starts_with("magnet:?xt=urn:btih:ABC123&dn=Some.Show.S01E01")); + assert!(magnet.contains("tracker.opentrackr.org")); + } + + #[test] + fn parses_a_real_captured_response() { + let body = r#"[{"id":"51630137","name":"Modern Family S01 (1080p BluRay)","info_hash":"8F87C7C186172F17E35F4512BB1A3E93B614ADED","leechers":"309","seeders":"379","size":"5395577424","num_files":"28","username":"rjaa","added":"1629816694","status":"vip","category":"208","imdb":""}]"#; + let results: Vec = serde_json::from_str(body).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].name, "Modern Family S01 (1080p BluRay)"); + assert_eq!(results[0].seeders, "379"); + } + + #[test] + fn filters_out_the_no_results_sentinel() { + let body = r#"[{"id":"0","name":"No results returned","info_hash":"0000000000000000000000000000000000000000","leechers":"0","seeders":"0","size":"0","num_files":"0","username":"","added":"0","status":"","category":"0","imdb":""}]"#; + let results: Vec = serde_json::from_str(body).unwrap(); + let filtered: Vec<_> = results + .into_iter() + .filter(|r| r.id != "0" && !r.info_hash.chars().all(|c| c == '0')) + .collect(); + assert!(filtered.is_empty()); + } +} diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..383929c --- /dev/null +++ b/config.example.toml @@ -0,0 +1,99 @@ +# Copy to ~/.config/breadarr/breadarrd.toml and fill in the empty values. +# Every field has a sensible default (see breadarr-shared/src/config.rs) — +# this file lists them explicitly for reference, not because all are required. + +[daemon] +log_level = "info" +listen_addr = "127.0.0.1:7879" +db_path = "~/.local/share/breadarr/breadarr.db" +# Embedding model for title matching — downloaded automatically on first +# run if not already present (~90MB, one-time). +model_dir = "~/.cache/breadarr/models/all-MiniLM-L6-v2" +# Empty (default) means no auth at all. Set this if listen_addr is ever +# changed to bind non-loopback (e.g. so a TUI on another host on the same +# tailnet can reach it) — otherwise that's unauthenticated add/delete/search +# access to anyone who can reach the port. /health is always exempt. +api_token = "" + +[qbit] +base_url = "http://127.0.0.1:8090" +username = "" +password = "" +category = "breadarr" +# Only needed if qBittorrent runs in a container (its own downloads mounted +# at some internal prefix) while breadarr runs natively on the same host — +# translates qBittorrent's reported paths into ones breadarr can open. +# Leave both empty if qBittorrent's reported paths are already host paths. +container_downloads_path = "" +host_downloads_path = "" + +[jellyfin] +base_url = "http://127.0.0.1:8096" +api_key = "" + +[notifications] +# Push notifications for things that would otherwise sit invisible until +# the TUI is next opened: releases needing manual confirmation, a run of +# import failures, the search loop halting. Empty (default) disables this +# entirely. POSTs {"title", "message"} JSON — Gotify's own message API +# accepts this directly (e.g. "http://127.0.0.1:5600/message?token=..."); +# most other self-hosted webhook receivers accept the same shape. +webhook_url = "" + +[tvdb] +# https://thetvdb.com/dashboard/account/apikeys — v4 key, "Fan/Personal" tier +api_key = "" + +[tmdb] +# https://www.themoviedb.org/settings/api — "API Read Access Token (v4 auth)", +# not the shorter v3 "API Key" +bearer_token = "" + +[library] +# Default root folder for shows added via the TUI's "Add Show" flow. +default_root_folder = "~/breadarr-library" + +[sources] +# nyaa's English-translated anime category — the daemon polls this +# automatically and grabs anything matching a monitored, missing episode. +nyaa_rss_url = "https://nyaa.si/?page=rss&c=1_2" +grab_poll_interval_secs = 300 +import_poll_interval_secs = 60 +# Search-driven acquisition (movies + non-anime TV via 1337x, anime movies +# via nyaa's search mode) — unlike the nyaa RSS feed watch above, this +# actively queries a Cloudflare-fronted service with a ban history, so keep +# the budget conservative. `search_enabled = false` is the kill switch; a +# restart resets all in-memory mirror cooldown/backoff state, so this is the +# only reliable way to keep it off across restarts. +search_poll_interval_secs = 1800 +search_budget_per_cycle = 5 +search_enabled = true +# Upgrade-search — periodically re-checks episodes/movies that already have +# a file, in case a better release now exists (repacks/propers always +# supersede; anything else needs to beat the current file's score by at +# least upgrade_min_score_gain to avoid re-grabbing over marginal deltas). +# Much longer interval than search_poll_interval_secs since there's no +# urgency once content is already satisfied. +upgrade_enabled = true +upgrade_poll_interval_secs = 21600 +upgrade_budget_per_cycle = 3 +upgrade_min_score_gain = 5.0 +# 1337x's main domain bans IPs at the Cloudflare WAF level after bursts of +# automated traffic; these community mirrors are round-robined (not just +# tried in a fixed fallback order), with a failing mirror demoted into a +# cooldown rather than re-probed on the very next search. +torrent_1337x_mirrors = [ + "https://13377x.info", + "https://13377x.email", + "https://1337xto.info", + "https://1337x.maskbay.info", + "https://1337x.ninjaproxy.live", + "https://1337x.proxyhive.pro", + "https://1337x.torproxy.live", + "https://1337x.unblockit.world", + "https://1337x.unblockpirate.xyz", + "https://1337x.unblockshark.info", + "https://1337x.unblocktorrent.click", + "https://1337x.unblocktorrent.info", + "https://1337x.unblocktor.xyz", +] diff --git a/packaging/systemd/breadarrd.service b/packaging/systemd/breadarrd.service new file mode 100644 index 0000000..46525b5 --- /dev/null +++ b/packaging/systemd/breadarrd.service @@ -0,0 +1,23 @@ +[Unit] +Description=breadarr media acquisition daemon +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/breadarrd +# `always`, not `on-failure` — a clean-but-wrong exit (e.g. the background +# grab/import loop falling out of its own retry logic) should still trigger +# a restart, not leave the daemon dead until someone notices by hand. +Restart=always +RestartSec=2 +# 0022, not a restrictive 0077 — breadarrd writes into media library folders +# that Jellyfin (or another user/process) needs to read. +UMask=0022 +RuntimeDirectory=breadarr +RuntimeDirectoryMode=0700 +KillSignal=SIGTERM +TimeoutStopSec=5 + +[Install] +WantedBy=default.target