Switch to tag-pinned bread-ecosystem deps; bump version to v1.0

This commit is contained in:
Breadway 2026-07-19 03:27:37 +08:00
parent 2e32488b26
commit bb4576915e
9 changed files with 234 additions and 68 deletions

View file

@ -218,6 +218,15 @@ pub(crate) fn walk_files(dir: &Path) -> Result<Vec<PathBuf>> {
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(
series_title: &str,
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.
}
let (root_folder, filename, episode_id) = match grab {
let (dest_dir, filename, episode_id) = match grab {
PendingGrab::Episode {
series_title,
season_number,
@ -1550,7 +1559,8 @@ fn import_one(conn: &Connection, grab: &PendingGrab, content_path: &Path) -> Res
episode_title.as_deref(),
&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 {
title,
@ -1559,7 +1569,7 @@ fn import_one(conn: &Connection, grab: &PendingGrab, content_path: &Path) -> Res
..
} => {
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
// `import_season_pack` and never reaches this function with one.
@ -1568,8 +1578,8 @@ fn import_one(conn: &Connection, grab: &PendingGrab, content_path: &Path) -> Res
),
};
std::fs::create_dir_all(root_folder)?;
let dest = Path::new(root_folder).join(&filename);
std::fs::create_dir_all(&dest_dir)?;
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
@ -1645,12 +1655,13 @@ fn import_one(conn: &Connection, grab: &PendingGrab, content_path: &Path) -> Res
}
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!(
"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()
);
}
@ -1948,7 +1959,9 @@ fn import_season_pack_file(
episode_title.as_deref(),
&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
// release for the same episode (here, from a *different* pack or a
@ -1976,12 +1989,13 @@ fn import_season_pack_file(
}
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!(
"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()
);
}
@ -3150,6 +3164,78 @@ mod tests {
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]
fn import_one_flags_quality_when_the_real_file_is_under_1080p() {
// Mirrors a real production case: a release whose title carries no
@ -3604,9 +3690,18 @@ mod tests {
.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());
assert!(dest_root
.join("Season 01")
.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();
}
@ -3623,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.S01E02.mkv"), b"new e02").unwrap();
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.
conn.execute(
@ -3632,7 +3728,7 @@ mod tests {
[],
)
.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();
conn.execute(
"INSERT INTO episode_file (episode_id, media_item_id, path, size_bytes, subtitle_status)
@ -3659,7 +3755,7 @@ mod tests {
b"already-better e01",
"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();
}

View file

@ -188,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*
/// 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.
/// 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 {
let tokenize = |s: &str| -> std::collections::HashSet<String> {
s.split(|c: char| !c.is_alphanumeric())
.filter(|t| !t.is_empty())
.map(|t| t.to_lowercase())
.filter(|t| !STOPWORDS.contains(&t.as_str()))
.collect()
};
let ta = tokenize(a);
@ -273,4 +286,24 @@ mod tests {
fn empty_input_has_zero_overlap() {
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
);
}
}