fix: tv show matching

This commit is contained in:
Lucas Oskorep
2026-08-12 21:08:17 -04:00
parent 5a000b2431
commit a45fae8b17
4 changed files with 80 additions and 64 deletions
Generated
+1 -1
View File
@@ -651,7 +651,7 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]] [[package]]
name = "jelly-dedup" name = "jelly-dedup"
version = "0.1.1" version = "0.1.2"
dependencies = [ dependencies = [
"clap", "clap",
"dotenv", "dotenv",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "jelly-dedup" name = "jelly-dedup"
version = "0.1.1" version = "0.1.2"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+35 -6
View File
@@ -1,12 +1,41 @@
use crate::models::{Episode, Movie}; use crate::models::{Episode, Movie};
use std::collections::HashMap; use std::collections::HashMap;
/// Filters episodes to return only those with multiple media sources (duplicates) /// Groups episodes covering the same season/episode number, returning only the groups
pub fn filter_duplicate_episodes(episodes: Vec<Episode>) -> Vec<Episode> { /// that hold more than one file. Duplicates show up two different ways depending on how
episodes /// Jellyfin matched the files: merged into one item with several media sources, or as
.into_iter() /// separate items sharing a season/episode number.
.filter(|ep| has_multiple_versions_episode(ep)) pub fn filter_duplicate_episodes(episodes: Vec<Episode>) -> Vec<Vec<Episode>> {
.collect() let mut episode_map: HashMap<(u32, u32), Vec<Episode>> = HashMap::new();
let mut unnumbered: Vec<Vec<Episode>> = 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<Vec<Episode>> = 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 { fn has_multiple_versions_episode(episode: &Episode) -> bool {
+43 -56
View File
@@ -7,15 +7,15 @@ pub struct FileToDelete {
pub size: i64, pub size: i64,
} }
pub fn print_duplicate_episodes(show_name: &str, episodes: Vec<Episode>) -> Vec<FileToDelete> { pub fn print_duplicate_episodes(show_name: &str, episode_groups: Vec<Vec<Episode>>) -> Vec<FileToDelete> {
println!("\n📺 Show: {}", show_name); println!("\n📺 Show: {}", show_name);
println!("{}", "-".repeat(80)); 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(); let mut files_to_delete = Vec::new();
for episode in episodes { for group in episode_groups {
let to_delete = print_episode_with_versions(episode); let to_delete = print_episode_with_versions(group);
files_to_delete.extend(to_delete); files_to_delete.extend(to_delete);
} }
@@ -24,54 +24,54 @@ pub fn print_duplicate_episodes(show_name: &str, episodes: Vec<Episode>) -> Vec<
files_to_delete files_to_delete
} }
fn print_episode_with_versions(episode: Episode) -> Vec<FileToDelete> { fn print_episode_with_versions(group: Vec<Episode>) -> Vec<FileToDelete> {
let season = episode.season_number.unwrap_or(0); let first = match group.first() {
let ep_num = episode.episode_number.unwrap_or(0); Some(episode) => episode,
None => return Vec::new(),
};
let version_count = episode let season = first.season_number.unwrap_or(0);
.media_sources let ep_num = first.episode_number.unwrap_or(0);
.as_ref()
.map(|ms| ms.len())
.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!( println!(
" S{:02}E{:02} - {} ({} versions)", " 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 { /// Flattens media sources from several items into one list, skipping repeated paths
// Select the best source fn collect_unique_sources<'a>(
if let Some(best_idx) = selector::select_best_source(&media_sources) { sources: impl Iterator<Item = &'a Option<Vec<MediaSource>>>,
// Print selected file ) -> Vec<MediaSource> {
println!(" [SELECTED]"); let mut all_sources: Vec<MediaSource> = Vec::new();
print_media_source(&media_sources[best_idx]); let mut seen_paths: std::collections::HashSet<String> = std::collections::HashSet::new();
// Print non-selected files for media_sources in sources.flatten() {
if media_sources.len() > 1 { for source in media_sources {
println!(" [TO DELETE]"); // Only add if we haven't seen this path before
for (idx, source) in media_sources.iter().enumerate() { if let Some(path) = &source.path {
if idx != best_idx { if seen_paths.insert(path.clone()) {
print_media_source(source); all_sources.push(source.clone());
if let Some(path) = &source.path {
let size = source.size.unwrap_or(0);
files_to_delete.push(FileToDelete {
path: path.clone(),
size,
});
}
}
} }
} }
} }
} }
println!(); all_sources
files_to_delete
} }
fn print_media_source(source: &MediaSource) { fn print_media_source(source: &MediaSource) {
@@ -153,7 +153,7 @@ pub fn print_duplicate_movies(movie_groups: Vec<Vec<Movie>>) -> Vec<FileToDelete
if let Some(media_sources) = &movie.media_sources { if let Some(media_sources) = &movie.media_sources {
println!(" Multiple versions found: {}\n", media_sources.len()); println!(" Multiple versions found: {}\n", media_sources.len());
let to_delete = print_movie_versions(&movie.name, media_sources); let to_delete = print_versions(media_sources);
files_to_delete.extend(to_delete); files_to_delete.extend(to_delete);
} }
} else { } else {
@@ -164,24 +164,10 @@ pub fn print_duplicate_movies(movie_groups: Vec<Vec<Movie>>) -> Vec<FileToDelete
println!(" Multiple copies found: {}\n", movie_group.len()); println!(" Multiple copies found: {}\n", movie_group.len());
// Collect all media sources from all movies and deduplicate by path // Collect all media sources from all movies and deduplicate by path
let mut all_sources: Vec<MediaSource> = Vec::new(); let all_sources = collect_unique_sources(movie_group.iter().map(|m| &m.media_sources));
let mut seen_paths: std::collections::HashSet<String> = 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());
}
}
}
}
}
if !all_sources.is_empty() { 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); 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<MediaSource>) -> Vec<FileToDelete> { /// Prints every version, marking the best one as selected and the rest for deletion
fn print_versions(media_sources: &[MediaSource]) -> Vec<FileToDelete> {
let mut files_to_delete = Vec::new(); let mut files_to_delete = Vec::new();
if let Some(best_idx) = selector::select_best_source(media_sources) { if let Some(best_idx) = selector::select_best_source(media_sources) {