From a45fae8b173c54272d40bb13f769549c5997bce0 Mon Sep 17 00:00:00 2001 From: Lucas Oskorep Date: Wed, 12 Aug 2026 21:08:17 -0400 Subject: [PATCH] fix: tv show matching --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/analyzer.rs | 41 +++++++++++++++++--- src/display.rs | 99 +++++++++++++++++++++---------------------------- 4 files changed, 80 insertions(+), 64 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1412af0..4cc57f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -651,7 +651,7 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jelly-dedup" -version = "0.1.1" +version = "0.1.2" dependencies = [ "clap", "dotenv", diff --git a/Cargo.toml b/Cargo.toml index 53b7111..6304777 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jelly-dedup" -version = "0.1.1" +version = "0.1.2" edition = "2024" [dependencies] diff --git a/src/analyzer.rs b/src/analyzer.rs index f7eb61a..7ed5aa4 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -1,12 +1,41 @@ use crate::models::{Episode, Movie}; use std::collections::HashMap; -/// Filters episodes to return only those with multiple media sources (duplicates) -pub fn filter_duplicate_episodes(episodes: Vec) -> Vec { - episodes - .into_iter() - .filter(|ep| has_multiple_versions_episode(ep)) - .collect() +/// Groups episodes covering the same season/episode number, returning only the groups +/// that hold more than one file. Duplicates show up two different ways depending on how +/// Jellyfin matched the files: merged into one item with several media sources, or as +/// separate items sharing a season/episode number. +pub fn filter_duplicate_episodes(episodes: Vec) -> Vec> { + let mut episode_map: HashMap<(u32, u32), Vec> = HashMap::new(); + let mut unnumbered: Vec> = Vec::new(); + + for episode in episodes { + match (episode.season_number, episode.episode_number) { + (Some(season), Some(number)) => { + episode_map.entry((season, number)).or_default().push(episode); + } + // Without a season/episode number there is nothing safe to match on, so these + // are kept apart and only checked for multiple media sources. Grouping them + // together would coalesce unrelated specials into one bogus duplicate set. + _ => unnumbered.push(vec![episode]), + } + } + + let mut groups: Vec> = episode_map + .into_values() + .chain(unnumbered) + .filter(|group| group.len() > 1 || has_multiple_versions_episode(&group[0])) + .collect(); + + // HashMap iteration order is arbitrary; sort so output is stable between runs + groups.sort_by_key(|group| { + ( + group[0].season_number.unwrap_or(u32::MAX), + group[0].episode_number.unwrap_or(u32::MAX), + ) + }); + + groups } fn has_multiple_versions_episode(episode: &Episode) -> bool { diff --git a/src/display.rs b/src/display.rs index 9ce72b5..60ff372 100644 --- a/src/display.rs +++ b/src/display.rs @@ -7,15 +7,15 @@ pub struct FileToDelete { pub size: i64, } -pub fn print_duplicate_episodes(show_name: &str, episodes: Vec) -> Vec { +pub fn print_duplicate_episodes(show_name: &str, episode_groups: Vec>) -> Vec { println!("\n📺 Show: {}", show_name); println!("{}", "-".repeat(80)); - println!(" Episodes with multiple versions: {}\n", episodes.len()); + println!(" Episodes with multiple versions: {}\n", episode_groups.len()); let mut files_to_delete = Vec::new(); - for episode in episodes { - let to_delete = print_episode_with_versions(episode); + for group in episode_groups { + let to_delete = print_episode_with_versions(group); files_to_delete.extend(to_delete); } @@ -24,54 +24,54 @@ pub fn print_duplicate_episodes(show_name: &str, episodes: Vec) -> Vec< files_to_delete } -fn print_episode_with_versions(episode: Episode) -> Vec { - let season = episode.season_number.unwrap_or(0); - let ep_num = episode.episode_number.unwrap_or(0); +fn print_episode_with_versions(group: Vec) -> Vec { + let first = match group.first() { + Some(episode) => episode, + None => return Vec::new(), + }; - let version_count = episode - .media_sources - .as_ref() - .map(|ms| ms.len()) - .unwrap_or(0); + let season = first.season_number.unwrap_or(0); + let ep_num = first.episode_number.unwrap_or(0); - let episode_name = episode.name.as_deref().unwrap_or("Unknown Episode"); + // Not every episode carries a name, so take the first one that does + let episode_name = group + .iter() + .find_map(|episode| episode.name.as_deref()) + .unwrap_or("Unknown Episode"); + + // Gather the files from every item in the group, deduplicated by path + let all_sources = collect_unique_sources(group.iter().map(|ep| &ep.media_sources)); println!( " S{:02}E{:02} - {} ({} versions)", - season, ep_num, episode_name, version_count + season, + ep_num, + episode_name, + all_sources.len() ); - let mut files_to_delete = Vec::new(); + print_versions(&all_sources) +} - if let Some(media_sources) = episode.media_sources { - // Select the best source - if let Some(best_idx) = selector::select_best_source(&media_sources) { - // Print selected file - println!(" [SELECTED]"); - print_media_source(&media_sources[best_idx]); +/// Flattens media sources from several items into one list, skipping repeated paths +fn collect_unique_sources<'a>( + sources: impl Iterator>>, +) -> Vec { + let mut all_sources: Vec = Vec::new(); + let mut seen_paths: std::collections::HashSet = std::collections::HashSet::new(); - // Print non-selected files - if media_sources.len() > 1 { - println!(" [TO DELETE]"); - for (idx, source) in media_sources.iter().enumerate() { - if idx != best_idx { - print_media_source(source); - if let Some(path) = &source.path { - let size = source.size.unwrap_or(0); - files_to_delete.push(FileToDelete { - path: path.clone(), - size, - }); - } - } + for media_sources in sources.flatten() { + for source in media_sources { + // Only add if we haven't seen this path before + if let Some(path) = &source.path { + if seen_paths.insert(path.clone()) { + all_sources.push(source.clone()); } } } } - println!(); - - files_to_delete + all_sources } fn print_media_source(source: &MediaSource) { @@ -153,7 +153,7 @@ pub fn print_duplicate_movies(movie_groups: Vec>) -> Vec>) -> Vec = Vec::new(); - let mut seen_paths: std::collections::HashSet = std::collections::HashSet::new(); - - for movie in &movie_group { - if let Some(media_sources) = &movie.media_sources { - for source in media_sources { - // Only add if we haven't seen this path before - if let Some(path) = &source.path { - if seen_paths.insert(path.clone()) { - all_sources.push(source.clone()); - } - } - } - } - } + let all_sources = collect_unique_sources(movie_group.iter().map(|m| &m.media_sources)); if !all_sources.is_empty() { - let to_delete = print_movie_versions(&first_movie.name, &all_sources); + let to_delete = print_versions(&all_sources); files_to_delete.extend(to_delete); } } @@ -200,7 +186,8 @@ fn format_movie_title(movie: &Movie) -> String { } } -fn print_movie_versions(_movie_name: &str, media_sources: &Vec) -> Vec { +/// Prints every version, marking the best one as selected and the rest for deletion +fn print_versions(media_sources: &[MediaSource]) -> Vec { let mut files_to_delete = Vec::new(); if let Some(best_idx) = selector::select_best_source(media_sources) {