Compare commits

...

5 commits

Author SHA1 Message Date
Breadway
bb4576915e Switch to tag-pinned bread-ecosystem deps; bump version to v1.0 2026-07-19 03:27:37 +08:00
Breadway
2e32488b26 Remove orphaned bakery.toml — nothing serves it
breadarr has no .forgejo/workflows at all (no mirror, release, or
package workflow) and no PKGBUILD, and it isn't listed in
bread-ecosystem's registry/bread-ecosystem.toml, so gen-index.sh would
never pick it up even if a release workflow existed. The bakery.toml
here was pure dead metadata.

Not wiring up a release workflow instead: breadarr isn't in the
registry and its distribution channel (bakery, pacman, both, neither)
hasn't actually been decided, so adding one here would be inventing
a channel commitment I have no signal for. If/when breadarr is ready
to ship, follow docs/release-channels.md in bread-ecosystem to add it
properly (bakery.toml + registry entry + release.yml, or a PKGBUILD +
package.yml, or both).
2026-07-17 14:02:59 +08:00
Breadway
830e80622e Fix literal-tilde fallback bug in breadarr-shared's expand_home
expand_home() fell through to PathBuf::from(input) — the literal,
unexpanded "~/..." string — whenever the HOME env var itself wasn't set,
same bug class as breadclip-core/breadpad-shared/breadmon (found during
this pass's own crate-migration sweep, in a different shape here: the bug
was in this crate's own tilde-expansion helper rather than a
dirs::xxx().unwrap_or_else() chain). Fixed via bread_utils::xdg::home_dir,
which resolves a real home directory before ever needing to fall back.

Builds and tests clean: 205 passed, 1 pre-existing network-dependent test
ignored, 0 failed.
2026-07-17 10:13:06 +08:00
Breadway
8a2936b8fd Migrate embedding pipeline and model download to bread-onnx
OrtEmbedder's tokenize -> tensor build -> mean-pool -> L2-normalize
pipeline was near-byte-identical to breadmill's own OrtEmbedder (same
truncation, same actual_seq.min(mask.len()) padding guard, same 1e-10
epsilon) — now both share bread_onnx::embedding::EmbeddingSession (path
dependency for now, see the TODO in breadarrd/Cargo.toml). This crate
stays CPU-only (Provider::Cpu), matching its existing documented rationale.

ensure_model's reqwest-based download function is replaced with
bread_onnx::download::ensure_file (sync/ureq, matching breadmill's own
downloader and this workspace's bakery convention) dispatched via
spawn_blocking from this async context.

Builds and tests clean across the whole breadarr workspace: 205 passed, 1
pre-existing network-dependent test ignored, 0 failed.
2026-07-17 09:37:55 +08:00
Breadway
d35d9a1703 importer: rename old file aside instead of deleting it before placing the upgrade replacement
Both import_one and import_season_pack_file deleted the existing
episode_file row and unlinked the on-disk file *before* link_or_copy_file
placed the new one. If the link/copy (or the free-space check) then
failed, the original content and its DB row were already gone with
nothing to fall back to.

Now the stale file is renamed to a  sibling (freeing dest for the
hardlink fast path, same as before) and the DB row is left alone. Only
after the replacement is confirmed on disk are the  file and the
old row cleaned up; any failure in between restores the original file
before returning the error.
2026-07-17 06:51:06 +08:00
12 changed files with 654 additions and 260 deletions

10
.idea/.gitignore generated vendored Normal file
View file

@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

13
.idea/breadarr.iml generated Normal file
View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/breadarr-shared/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/breadarr-tui/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/breadarrd/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml generated Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/breadarr.iml" filepath="$PROJECT_DIR$/.idea/breadarr.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

426
Cargo.lock generated
View file

@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "ahash" name = "ahash"
version = "0.8.12" version = "0.8.12"
@ -156,15 +162,48 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.0" version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bread-onnx"
version = "0.3.0"
dependencies = [
"anyhow",
"bread-utils",
"hex",
"ort",
"sha2",
"tokenizers",
"tracing",
"ureq 2.12.1",
]
[[package]]
name = "bread-utils"
version = "0.3.0"
dependencies = [
"dirs",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "breadarr-shared" name = "breadarr-shared"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bread-utils",
"chrono", "chrono",
"reqwest", "reqwest",
"serde", "serde",
@ -192,6 +231,7 @@ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"axum", "axum",
"bread-onnx",
"breadarr-shared", "breadarr-shared",
"chrono", "chrono",
"fastrand", "fastrand",
@ -245,9 +285,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.66" version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@ -261,9 +301,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "cfg_aliases" name = "cfg_aliases"
version = "0.2.1" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]] [[package]]
name = "chrono" name = "chrono"
@ -375,6 +415,24 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "crossbeam-deque" name = "crossbeam-deque"
version = "0.8.7" version = "0.8.7"
@ -425,6 +483,16 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]] [[package]]
name = "cssparser" name = "cssparser"
version = "0.31.2" version = "0.31.2"
@ -590,6 +658,37 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.6" version = "0.2.6"
@ -701,6 +800,16 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@ -749,36 +858,36 @@ dependencies = [
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.32" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
dependencies = [ dependencies = [
"futures-core", "futures-core",
] ]
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.32" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
[[package]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.32" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.32" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.32" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-task", "futures-task",
@ -795,6 +904,16 @@ dependencies = [
"byteorder", "byteorder",
] ]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]] [[package]]
name = "getopts" name = "getopts"
version = "0.2.24" version = "0.2.24"
@ -898,6 +1017,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]] [[package]]
name = "hmac-sha256" name = "hmac-sha256"
version = "1.1.14" version = "1.1.14"
@ -930,9 +1055,9 @@ dependencies = [
[[package]] [[package]]
name = "http-body" name = "http-body"
version = "1.0.1" version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
dependencies = [ dependencies = [
"bytes", "bytes",
"http", "http",
@ -940,9 +1065,9 @@ dependencies = [
[[package]] [[package]]
name = "http-body-util" name = "http-body-util"
version = "0.1.3" version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
dependencies = [ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
@ -1272,6 +1397,15 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libredox"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "libsqlite3-sys" name = "libsqlite3-sys"
version = "0.28.0" version = "0.28.0"
@ -1390,9 +1524,9 @@ checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]] [[package]]
name = "matrixmultiply" name = "matrixmultiply"
version = "0.3.10" version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"rawpointer", "rawpointer",
@ -1427,10 +1561,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "mio" name = "miniz_oxide"
version = "1.2.1" version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [ dependencies = [
"libc", "libc",
"log", "log",
@ -1633,6 +1777,12 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]] [[package]]
name = "ort" name = "ort"
version = "2.0.0-rc.12" version = "2.0.0-rc.12"
@ -1643,7 +1793,7 @@ dependencies = [
"ort-sys", "ort-sys",
"smallvec", "smallvec",
"tracing", "tracing",
"ureq", "ureq 3.3.0",
] ]
[[package]] [[package]]
@ -1654,7 +1804,7 @@ checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90"
dependencies = [ dependencies = [
"hmac-sha256", "hmac-sha256",
"lzma-rust2", "lzma-rust2",
"ureq", "ureq 3.3.0",
] ]
[[package]] [[package]]
@ -1805,9 +1955,9 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.13.1" version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
[[package]] [[package]]
name = "portable-atomic-util" name = "portable-atomic-util"
@ -2030,10 +2180,21 @@ dependencies = [
] ]
[[package]] [[package]]
name = "regex" name = "redox_users"
version = "1.13.0" version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
@ -2043,9 +2204,9 @@ dependencies = [
[[package]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.15" version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
@ -2158,11 +2319,13 @@ dependencies = [
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.41" version = "0.23.42"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
dependencies = [ dependencies = [
"log",
"once_cell", "once_cell",
"ring",
"rustls-pki-types", "rustls-pki-types",
"rustls-webpki", "rustls-webpki",
"subtle", "subtle",
@ -2358,6 +2521,17 @@ dependencies = [
"stable_deref_trait", "stable_deref_trait",
] ]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@ -2404,6 +2578,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "0.3.11" version = "0.3.11"
@ -2430,9 +2610,9 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.4" version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@ -2534,9 +2714,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.118" version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -2608,13 +2788,33 @@ dependencies = [
"utf-8", "utf-8",
] ]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.18" version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl 2.0.18",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
] ]
[[package]] [[package]]
@ -2705,7 +2905,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"spm_precompiled", "spm_precompiled",
"thiserror", "thiserror 2.0.18",
"unicode-normalization-alignments", "unicode-normalization-alignments",
"unicode-segmentation", "unicode-segmentation",
"unicode_categories", "unicode_categories",
@ -2713,9 +2913,9 @@ dependencies = [
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.52.3" version = "1.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
@ -2730,9 +2930,9 @@ dependencies = [
[[package]] [[package]]
name = "tokio-macros" name = "tokio-macros"
version = "2.7.0" version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -2927,6 +3127,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]] [[package]]
name = "unicase" name = "unicase"
version = "2.9.0" version = "2.9.0"
@ -2995,6 +3201,24 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64 0.22.1",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"url",
"webpki-roots 0.26.11",
]
[[package]] [[package]]
name = "ureq" name = "ureq"
version = "3.3.0" version = "3.3.0"
@ -3174,9 +3398,27 @@ dependencies = [
[[package]] [[package]]
name = "webpki-root-certs" name = "webpki-root-certs"
version = "1.0.8" version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
@ -3273,13 +3515,22 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [ dependencies = [
"windows-targets", "windows-targets 0.52.6",
] ]
[[package]] [[package]]
@ -3288,7 +3539,7 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [ dependencies = [
"windows-targets", "windows-targets 0.52.6",
] ]
[[package]] [[package]]
@ -3300,34 +3551,67 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [ dependencies = [
"windows_aarch64_gnullvm", "windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc", "windows_aarch64_msvc 0.52.6",
"windows_i686_gnu", "windows_i686_gnu 0.52.6",
"windows_i686_gnullvm", "windows_i686_gnullvm",
"windows_i686_msvc", "windows_i686_msvc 0.52.6",
"windows_x86_64_gnu", "windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm", "windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc", "windows_x86_64_msvc 0.52.6",
] ]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
@ -3340,24 +3624,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
@ -3490,6 +3798,6 @@ dependencies = [
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"

View file

@ -1,21 +0,0 @@
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",
]

View file

@ -9,3 +9,5 @@ anyhow.workspace = true
toml.workspace = true toml.workspace = true
reqwest.workspace = true reqwest.workspace = true
chrono.workspace = true chrono.workspace = true
# TODO(owner): switch to tag-pinned git dependency once bread-utils is merged and tagged, matching the bread-theme pattern
bread-utils = { path = "../../bread-ecosystem/bread-utils" }

View file

@ -314,10 +314,16 @@ fn config_path() -> PathBuf {
} }
fn expand_home(input: &str) -> PathBuf { fn expand_home(input: &str) -> PathBuf {
// Was: falls through to `PathBuf::from(input)` — a literal, unexpanded
// "~/..." string — whenever the `HOME` env var itself isn't set.
// PathBuf/std::fs never expand `~`, so that fallback silently produced
// a path relative to the current working directory instead of the
// user's actual home. Same bug class as breadclip-core/breadpad-shared/
// breadmon (see bread_utils::xdg's doc comment); bread_utils::xdg::home_dir
// resolves a real home directory (falling back to `/root`, never a
// literal tilde) before this ever needs to fall back at all.
if let Some(stripped) = input.strip_prefix("~/") { if let Some(stripped) = input.strip_prefix("~/") {
if let Ok(home) = env::var("HOME") { return bread_utils::xdg::home_dir().join(stripped);
return Path::new(&home).join(stripped);
}
} }
PathBuf::from(input) PathBuf::from(input)
} }

View file

@ -19,6 +19,8 @@ regex.workspace = true
serde_json.workspace = true serde_json.workspace = true
ort.workspace = true ort.workspace = true
tokenizers.workspace = true tokenizers.workspace = true
# TODO(owner): switch to tag-pinned git dependency once bread-onnx is merged and tagged, matching the bread-theme pattern
bread-onnx = { path = "../../bread-ecosystem/bread-onnx" }
scraper.workspace = true scraper.workspace = true
chrono.workspace = true chrono.workspace = true
fastrand.workspace = true fastrand.workspace = true

View file

@ -218,6 +218,15 @@ pub(crate) fn walk_files(dir: &Path) -> Result<Vec<PathBuf>> {
Ok(out) Ok(out)
} }
/// TV episode files live under a `Season NN` subfolder of the show's
/// `root_folder`, matching the layout Jellyfin/Sonarr/library-scan already
/// use — episodes imported flat into the show root previously left new
/// grabs sitting alongside, rather than inside, the season structure that
/// pre-existing library files were organized into.
pub(crate) fn season_dir(root_folder: &str, season_number: u32) -> PathBuf {
Path::new(root_folder).join(format!("Season {season_number:02}"))
}
pub(crate) fn deterministic_filename( pub(crate) fn deterministic_filename(
series_title: &str, series_title: &str,
season: u32, season: u32,
@ -1533,7 +1542,7 @@ fn import_one(conn: &Connection, grab: &PendingGrab, content_path: &Path) -> Res
// clean import below, same as an already-English-default file. // clean import below, same as an already-English-default file.
} }
let (root_folder, filename, episode_id) = match grab { let (dest_dir, filename, episode_id) = match grab {
PendingGrab::Episode { PendingGrab::Episode {
series_title, series_title,
season_number, season_number,
@ -1550,7 +1559,8 @@ fn import_one(conn: &Connection, grab: &PendingGrab, content_path: &Path) -> Res
episode_title.as_deref(), episode_title.as_deref(),
&ext, &ext,
); );
(root_folder.as_str(), filename, Some(*episode_id)) let dest_dir = season_dir(root_folder, *season_number);
(dest_dir, filename, Some(*episode_id))
} }
PendingGrab::Movie { PendingGrab::Movie {
title, title,
@ -1559,7 +1569,7 @@ fn import_one(conn: &Connection, grab: &PendingGrab, content_path: &Path) -> Res
.. ..
} => { } => {
let filename = deterministic_movie_filename(title, *year, &ext); let filename = deterministic_movie_filename(title, *year, &ext);
(root_folder.as_str(), filename, None) (PathBuf::from(root_folder), filename, None)
} }
// `process_pending_grabs` dispatches `SeasonPack` to // `process_pending_grabs` dispatches `SeasonPack` to
// `import_season_pack` and never reaches this function with one. // `import_season_pack` and never reaches this function with one.
@ -1568,8 +1578,15 @@ fn import_one(conn: &Connection, grab: &PendingGrab, content_path: &Path) -> Res
), ),
}; };
std::fs::create_dir_all(root_folder)?; std::fs::create_dir_all(&dest_dir)?;
let dest = Path::new(root_folder).join(&filename); let dest = dest_dir.join(&filename);
// Set below (to the renamed-aside stale file's path) only when this
// import is an upgrade over an existing, worse-scoring file — see the
// `dest.exists()` branch. Used to restore the original on any failure
// between here and the replacement being confirmed on disk, and to
// gate the deferred cleanup (old row + old file) once it succeeds.
let mut old_sibling: Option<PathBuf> = None;
// The deterministic filename means a second release for the same // The deterministic filename means a second release for the same
// episode/movie collides on this exact path. `link_or_copy_file`'s copy // episode/movie collides on this exact path. `link_or_copy_file`'s copy
@ -1620,33 +1637,44 @@ fn import_one(conn: &Connection, grab: &PendingGrab, content_path: &Path) -> Res
} }
return Ok(ImportOutcome::SkippedAlreadyHaveBetter); return Ok(ImportOutcome::SkippedAlreadyHaveBetter);
} }
// The new file scores strictly better than what's currently there — // The new file scores strictly better than what's currently there.
// remove the stale file and its tracking row before placing the // Move the stale file sideways to a `.old` sibling — rather than
// replacement, rather than letting `link_or_copy_file`'s copy // deleting it and its tracking row outright — so the replacement is
// fallback silently overwrite it in place. This matters for two // placed and confirmed *before* the original is actually given up.
// reasons: it keeps exactly one `episode_file` row per episode (an // A `rename` (not a delete) still frees up `dest` for the primary
// old row left behind plus the fresh `INSERT` below would otherwise // hardlink path (`std::fs::hard_link` fails outright if the
// leave a duplicate — the same bug class found live in the // destination already exists), so an upgrade is still a cheap
// Dr.STONE/Battlestar Galactica rows), and it lets the primary // hardlink swap in the common case; it just also means that if the
// hardlink path actually succeed (`std::fs::hard_link` fails // free-space check or `link_or_copy_file` below fails (I/O error,
// outright if the destination already exists), so an upgrade is a // dest dir vanished, disk full), the original file gets moved back
// cheap hardlink swap instead of an unnecessary full copy. // into place instead of being gone for good. The DB row is left
conn.execute( // alone until the replacement is confirmed on disk, for the same
"DELETE FROM episode_file WHERE path = ?1", // reason.
params![dest.to_string_lossy()], old_sibling = Some(PathBuf::from(format!("{}.old", dest.display())));
)?; std::fs::rename(&dest, old_sibling.as_ref().unwrap())?;
std::fs::remove_file(&dest).ok();
} }
let needed_bytes = std::fs::metadata(&working_path)?.len(); let needed_bytes = std::fs::metadata(&working_path)?.len();
if insufficient_space(Path::new(root_folder), needed_bytes)? { if insufficient_space(&dest_dir, needed_bytes)? {
if let Some(old_sibling) = &old_sibling {
std::fs::rename(old_sibling, &dest).ok();
}
anyhow::bail!( anyhow::bail!(
"not enough free space at {root_folder} for {needed_bytes} bytes (source: {})", "not enough free space at {} for {needed_bytes} bytes (source: {})",
dest_dir.display(),
working_path.display() working_path.display()
); );
} }
link_or_copy_file(&working_path, &dest)?; if let Err(err) = link_or_copy_file(&working_path, &dest) {
// Restore the original file rather than leaving the user with
// neither the old file nor the new one — this is the exact failure
// mode a `DELETE`-then-place ordering used to leave unrecoverable.
if let Some(old_sibling) = &old_sibling {
std::fs::rename(old_sibling, &dest).ok();
}
return Err(err);
}
if remuxed { if remuxed {
// `working_path` here is the scratch remux output, not qBittorrent's // `working_path` here is the scratch remux output, not qBittorrent's
// original content file (which was already removed above, in the // original content file (which was already removed above, in the
@ -1655,6 +1683,16 @@ fn import_one(conn: &Connection, grab: &PendingGrab, content_path: &Path) -> Res
std::fs::remove_file(&working_path).ok(); std::fs::remove_file(&working_path).ok();
} }
// The replacement is confirmed in place on disk — only now is it safe
// to drop the old tracking row and the renamed-aside original.
if let Some(old_sibling) = old_sibling {
conn.execute(
"DELETE FROM episode_file WHERE path = ?1",
params![dest.to_string_lossy()],
)?;
std::fs::remove_file(&old_sibling).ok();
}
let size_bytes = std::fs::metadata(&dest)?.len(); let size_bytes = std::fs::metadata(&dest)?.len();
conn.execute( conn.execute(
"INSERT INTO episode_file (episode_id, media_item_id, path, size_bytes, subtitle_status) VALUES (?1, ?2, ?3, ?4, 'none')", "INSERT INTO episode_file (episode_id, media_item_id, path, size_bytes, subtitle_status) VALUES (?1, ?2, ?3, ?4, 'none')",
@ -1921,12 +1959,15 @@ fn import_season_pack_file(
episode_title.as_deref(), episode_title.as_deref(),
&ext, &ext,
); );
let dest = Path::new(root_folder).join(&filename); let dest_dir = season_dir(root_folder, season_number);
std::fs::create_dir_all(&dest_dir)?;
let dest = dest_dir.join(&filename);
// Same reasoning as import_one's own dest-collision check: a second // Same reasoning as import_one's own dest-collision check: a second
// release for the same episode (here, from a *different* pack or a // release for the same episode (here, from a *different* pack or a
// single-episode grab) must not silently overwrite a better file // single-episode grab) must not silently overwrite a better file
// already in place. // already in place.
let mut old_sibling: Option<PathBuf> = None;
if dest.exists() { if dest.exists() {
let existing_best: Option<f32> = conn.query_row( let existing_best: Option<f32> = conn.query_row(
"SELECT MAX(score) FROM release WHERE status = 'imported' AND id != ?1 AND episode_id = ?2", "SELECT MAX(score) FROM release WHERE status = 'imported' AND id != ?1 AND episode_id = ?2",
@ -1936,27 +1977,43 @@ fn import_season_pack_file(
if existing_best.is_some_and(|best| best >= release_score) { if existing_best.is_some_and(|best| best >= release_score) {
return Ok(PackFileOutcome::SkippedAlreadyHaveBetter); return Ok(PackFileOutcome::SkippedAlreadyHaveBetter);
} }
// This episode's file is being upgraded — clear the stale row and // This episode's file is being upgraded. Move the stale file aside
// file first so the hardlink below is a real hardlink swap rather // to a `.old` sibling rather than deleting it (and its row) outright
// than a copy-fallback overwrite, and so no duplicate episode_file // — `dest` is still free for the hardlink fast path, but the
// row survives. See import_one's matching comment for the fuller // original survives on disk until the replacement is confirmed in
// reasoning. // place, so a failed free-space check or `link_or_copy_file` below
conn.execute( // can't lose the file. See import_one's matching comment for the
"DELETE FROM episode_file WHERE path = ?1", // fuller reasoning; this is the same fix applied there.
params![dest.to_string_lossy()], old_sibling = Some(PathBuf::from(format!("{}.old", dest.display())));
)?; std::fs::rename(&dest, old_sibling.as_ref().unwrap())?;
std::fs::remove_file(&dest).ok();
} }
let needed_bytes = std::fs::metadata(source_path)?.len(); let needed_bytes = std::fs::metadata(source_path)?.len();
if insufficient_space(Path::new(root_folder), needed_bytes)? { if insufficient_space(&dest_dir, needed_bytes)? {
if let Some(old_sibling) = &old_sibling {
std::fs::rename(old_sibling, &dest).ok();
}
anyhow::bail!( anyhow::bail!(
"not enough free space at {root_folder} for {needed_bytes} bytes (source: {})", "not enough free space at {} for {needed_bytes} bytes (source: {})",
dest_dir.display(),
source_path.display() source_path.display()
); );
} }
link_or_copy_file(source_path, &dest)?; if let Err(err) = link_or_copy_file(source_path, &dest) {
if let Some(old_sibling) = &old_sibling {
std::fs::rename(old_sibling, &dest).ok();
}
return Err(err);
}
if let Some(old_sibling) = old_sibling {
conn.execute(
"DELETE FROM episode_file WHERE path = ?1",
params![dest.to_string_lossy()],
)?;
std::fs::remove_file(&old_sibling).ok();
}
let size_bytes = std::fs::metadata(&dest)?.len(); let size_bytes = std::fs::metadata(&dest)?.len();
conn.execute( conn.execute(
@ -3107,6 +3164,78 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap(); std::fs::remove_dir_all(&dir).unwrap();
} }
#[test]
fn import_one_places_a_single_episode_grab_under_its_season_subfolder() {
// Regression: a real single-episode grab (Mushoku Tensei S03E02/E03,
// not part of a season pack) landed flat in the show's root_folder
// instead of alongside the rest of the season's already-organized
// files in `Season 03/`, once the pre-existing library-scanned
// structure had nothing left to mask it.
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', 2021, 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 episode (id, media_item_id, season_number, episode_number, monitored, has_file)
VALUES (1, 1, 3, 2, 1, 0)",
[],
)
.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, 1, 'Some Show S03E02 1080p', 1, 'guid-1', 'grabbed', 'deadbeef', datetime('now'))",
[],
)
.unwrap();
let dir = std::env::temp_dir().join(format!(
"breadarr-single-episode-season-dir-{}",
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.Show.S03E02.1080p.mp4");
std::fs::write(&content, b"fake episode data").unwrap();
let grab = PendingGrab::Episode {
release_id: 1,
episode_id: 1,
media_item_id: 1,
torrent_hash: "deadbeef".to_string(),
series_title: "Some Show".to_string(),
season_number: 3,
episode_number: 2,
episode_title: None,
root_folder: dest_root.to_string_lossy().to_string(),
};
let outcome = import_one(&conn, &grab, &content).unwrap();
assert!(matches!(outcome, ImportOutcome::Imported { .. }));
let dest = dest_root.join("Season 03").join("Some Show - S03E02.mp4");
assert!(
dest.exists(),
"expected {} to exist under the season subfolder",
dest.display()
);
assert!(
!dest_root.join("Some Show - S03E02.mp4").exists(),
"must not also land flat in the show's root folder"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test] #[test]
fn import_one_flags_quality_when_the_real_file_is_under_1080p() { fn import_one_flags_quality_when_the_real_file_is_under_1080p() {
// Mirrors a real production case: a release whose title carries no // Mirrors a real production case: a release whose title carries no
@ -3561,9 +3690,18 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(status, "imported"); assert_eq!(status, "imported");
assert!(dest_root.join("Some Show - S01E01.mkv").exists()); assert!(dest_root
assert!(dest_root.join("Some Show - S01E02.mkv").exists()); .join("Season 01")
assert!(dest_root.join("Some Show - S01E03.mkv").exists()); .join("Some Show - S01E01.mkv")
.exists());
assert!(dest_root
.join("Season 01")
.join("Some Show - S01E02.mkv")
.exists());
assert!(dest_root
.join("Season 01")
.join("Some Show - S01E03.mkv")
.exists());
std::fs::remove_dir_all(&dir).unwrap(); std::fs::remove_dir_all(&dir).unwrap();
} }
@ -3580,7 +3718,8 @@ mod tests {
std::fs::write(pack_dir.join("Show.S01E01.mkv"), b"new e01").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(); std::fs::write(pack_dir.join("Show.S01E02.mkv"), b"new e02").unwrap();
let dest_root = dir.join("library"); let dest_root = dir.join("library");
std::fs::create_dir_all(&dest_root).unwrap(); let season_dir = dest_root.join("Season 01");
std::fs::create_dir_all(&season_dir).unwrap();
// Episode 1 already has a higher-scoring imported release. // Episode 1 already has a higher-scoring imported release.
conn.execute( conn.execute(
@ -3589,7 +3728,7 @@ mod tests {
[], [],
) )
.unwrap(); .unwrap();
let existing = dest_root.join("Some Show - S01E01.mkv"); let existing = season_dir.join("Some Show - S01E01.mkv");
std::fs::write(&existing, b"already-better e01").unwrap(); std::fs::write(&existing, b"already-better e01").unwrap();
conn.execute( conn.execute(
"INSERT INTO episode_file (episode_id, media_item_id, path, size_bytes, subtitle_status) "INSERT INTO episode_file (episode_id, media_item_id, path, size_bytes, subtitle_status)
@ -3616,7 +3755,7 @@ mod tests {
b"already-better e01", b"already-better e01",
"episode 1's better file must survive untouched" "episode 1's better file must survive untouched"
); );
assert!(dest_root.join("Some Show - S01E02.mkv").exists()); assert!(season_dir.join("Some Show - S01E02.mkv").exists());
std::fs::remove_dir_all(&dir).unwrap(); std::fs::remove_dir_all(&dir).unwrap();
} }

View file

@ -1,10 +1,8 @@
use std::path::Path; use std::path::Path;
use anyhow::Result; use anyhow::Result;
use ort::session::builder::GraphOptimizationLevel; use bread_onnx::embedding::EmbeddingSession;
use ort::session::Session; use bread_onnx::Provider;
use ort::value::Tensor;
use tokenizers::Tokenizer;
/// all-MiniLM-L6-v2's trained max sequence length. Release/show titles are /// 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 /// always far shorter than this, but truncate defensively rather than let a
@ -12,9 +10,7 @@ use tokenizers::Tokenizer;
const MAX_SEQ_LEN: usize = 256; const MAX_SEQ_LEN: usize = 256;
pub struct OrtEmbedder { pub struct OrtEmbedder {
session: Session, inner: EmbeddingSession,
tokenizer: Tokenizer,
dim: usize,
} }
impl OrtEmbedder { impl OrtEmbedder {
@ -23,130 +19,24 @@ impl OrtEmbedder {
/// MiniLM-class model is cheap enough on CPU that a multi-backend GPU /// 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. /// setup isn't worth the added complexity for a model this small.
pub fn load(model_path: &Path, tokenizer_path: &Path, dim: usize) -> Result<Self> { pub fn load(model_path: &Path, tokenizer_path: &Path, dim: usize) -> Result<Self> {
let session = Session::builder() let inner = EmbeddingSession::load(model_path, tokenizer_path, dim, MAX_SEQ_LEN, &[Provider::Cpu])?;
.map_err(|e| anyhow::anyhow!("failed to create ort session builder: {e}"))? Ok(Self { inner })
.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<Vec<f32>> { pub fn embed(&mut self, text: &str) -> Result<Vec<f32>> {
let encoding = self self.inner.embed(text)
.tokenizer
.encode(text, true)
.map_err(|e| anyhow::anyhow!("tokenization failed: {e}"))?;
let mut ids: Vec<i64> = encoding.get_ids().iter().map(|&x| x as i64).collect();
let mut mask: Vec<i64> = encoding
.get_attention_mask()
.iter()
.map(|&x| x as i64)
.collect();
let mut type_ids: Vec<i64> = 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::<i64>::from_array((vec![1i64, seq_len], ids))
.map_err(|e| anyhow::anyhow!("failed to build input_ids tensor: {e}"))?;
let mask_tensor = Tensor::<i64>::from_array((vec![1i64, seq_len], mask.clone()))
.map_err(|e| anyhow::anyhow!("failed to build attention_mask tensor: {e}"))?;
let type_tensor = Tensor::<i64>::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::<f32>()
.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); pub use bread_onnx::embedding::cosine_similarity;
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::<f32>().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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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::<f32>().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] #[test]
fn cosine_similarity_of_identical_unit_vectors_is_one() { fn cosine_similarity_of_identical_unit_vectors_is_one() {
let mut v = vec![1.0, 2.0, 3.0]; let v = vec![0.6, 0.8]; // already unit length
l2_normalize(&mut v);
let sim = cosine_similarity(&v, &v); let sim = cosine_similarity(&v, &v);
assert!((sim - 1.0).abs() < 1e-6); assert!((sim - 1.0).abs() < 1e-6);
} }

View file

@ -16,6 +16,13 @@ const TOKENIZER_URL: &str =
/// Downloads the embedding model into `model_dir` if it isn't already /// Downloads the embedding model into `model_dir` if it isn't already
/// there — keeps setup to "run the daemon," no separate fetch step, in /// there — keeps setup to "run the daemon," no separate fetch step, in
/// keeping with the project's minimal-setup goal. /// keeping with the project's minimal-setup goal.
///
/// `bread_onnx::download::ensure_file` is sync/blocking (`ureq`, matching
/// this workspace's `bakery` download convention) — this used to be a
/// `reqwest`-based async implementation local to this crate, genuinely
/// duplicating breadmill's own sync/`ureq` downloader. Since this fn is
/// called from an async context, each call is dispatched via
/// `spawn_blocking` rather than blocking the async runtime directly.
pub async fn ensure_model(model_dir: &Path) -> Result<(PathBuf, PathBuf)> { pub async fn ensure_model(model_dir: &Path) -> Result<(PathBuf, PathBuf)> {
std::fs::create_dir_all(model_dir) std::fs::create_dir_all(model_dir)
.with_context(|| format!("failed to create {}", model_dir.display()))?; .with_context(|| format!("failed to create {}", model_dir.display()))?;
@ -25,28 +32,19 @@ pub async fn ensure_model(model_dir: &Path) -> Result<(PathBuf, PathBuf)> {
if !model_path.exists() { if !model_path.exists() {
tracing::info!("downloading title-matching model (~90MB, one-time)"); tracing::info!("downloading title-matching model (~90MB, one-time)");
download(MODEL_URL, &model_path).await?; download(MODEL_URL, model_path.clone()).await?;
} }
if !tokenizer_path.exists() { if !tokenizer_path.exists() {
download(TOKENIZER_URL, &tokenizer_path).await?; download(TOKENIZER_URL, tokenizer_path.clone()).await?;
} }
Ok((model_path, tokenizer_path)) Ok((model_path, tokenizer_path))
} }
async fn download(url: &str, dest: &Path) -> Result<()> { async fn download(url: &'static str, dest: PathBuf) -> Result<()> {
let bytes = reqwest::get(url) tokio::task::spawn_blocking(move || bread_onnx::download::ensure_file(url, &dest, None))
.await .await
.with_context(|| format!("failed to download {url}"))? .context("download task panicked")??;
.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(()) Ok(())
} }
@ -190,15 +188,28 @@ impl TitleMatcher {
} }
} }
/// Grammatical filler words that show up across countless unrelated titles
/// and carry no discriminating signal on their own — English articles and
/// the short romaji particles ubiquitous in Japanese titles ("X no Y", "X ga
/// Y", ...). Without this, two completely unrelated shows sharing nothing
/// but e.g. "no" register as real overlap and slip past `MIN_TOKEN_OVERLAP`
/// (verified live: "Yomi no Tsugai" / "Kami no Shizuku" / "Saijo no Osewa"
/// all cleared the guard against "Kimetsu no Yaiba" on "no" alone).
const STOPWORDS: &[&str] = &[
"a", "an", "the", "of", "to", "and", "no", "wa", "wo", "ga", "ni", "de", "ha", "he", "da",
];
/// Word-level overlap between `a` and `b`, as a fraction of the *shorter* /// 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 /// 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 /// (or vice versa) still scores 1.0, rather than being penalized by length
/// mismatch. Case-insensitive, splits on non-alphanumeric runs. /// mismatch. Case-insensitive, splits on non-alphanumeric runs, and ignores
/// `STOPWORDS` so shared filler words don't count as real overlap.
fn token_overlap_ratio(a: &str, b: &str) -> f32 { fn token_overlap_ratio(a: &str, b: &str) -> f32 {
let tokenize = |s: &str| -> std::collections::HashSet<String> { let tokenize = |s: &str| -> std::collections::HashSet<String> {
s.split(|c: char| !c.is_alphanumeric()) s.split(|c: char| !c.is_alphanumeric())
.filter(|t| !t.is_empty()) .filter(|t| !t.is_empty())
.map(|t| t.to_lowercase()) .map(|t| t.to_lowercase())
.filter(|t| !STOPWORDS.contains(&t.as_str()))
.collect() .collect()
}; };
let ta = tokenize(a); let ta = tokenize(a);
@ -275,4 +286,24 @@ mod tests {
fn empty_input_has_zero_overlap() { fn empty_input_has_zero_overlap() {
assert_eq!(token_overlap_ratio("", "Mushoku Tensei"), 0.0); assert_eq!(token_overlap_ratio("", "Mushoku Tensei"), 0.0);
} }
#[test]
fn shared_particle_alone_is_not_real_overlap() {
// The bug found live in the review queue: unrelated titles sharing
// only the Japanese particle "no" scored a nonzero ratio and slipped
// past MIN_TOKEN_OVERLAP, flooding review with false matches against
// "Demon Slayer" (Kimetsu no Yaiba).
assert_eq!(
token_overlap_ratio("Yomi no Tsugai", "Kimetsu no Yaiba"),
0.0
);
assert_eq!(
token_overlap_ratio("Kami no Shizuku", "Kimetsu no Yaiba"),
0.0
);
assert_eq!(
token_overlap_ratio("Saijo no Osewa", "Kimetsu no Yaiba"),
0.0
);
}
} }