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]]
name = "jelly-dedup"
version = "0.1.1"
version = "0.1.2"
dependencies = [
"clap",
"dotenv",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jelly-dedup"
version = "0.1.1"
version = "0.1.2"
edition = "2024"
[dependencies]
+35 -6
View File
@@ -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<Episode>) -> Vec<Episode> {
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<Episode>) -> Vec<Vec<Episode>> {
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 {
+43 -56
View File
@@ -7,15 +7,15 @@ pub struct FileToDelete {
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!("{}", "-".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<Episode>) -> Vec<
files_to_delete
}
fn print_episode_with_versions(episode: Episode) -> Vec<FileToDelete> {
let season = episode.season_number.unwrap_or(0);
let ep_num = episode.episode_number.unwrap_or(0);
fn print_episode_with_versions(group: Vec<Episode>) -> Vec<FileToDelete> {
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<Item = &'a Option<Vec<MediaSource>>>,
) -> Vec<MediaSource> {
let mut all_sources: Vec<MediaSource> = Vec::new();
let mut seen_paths: std::collections::HashSet<String> = 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<Movie>>) -> Vec<FileToDelete
if let Some(media_sources) = &movie.media_sources {
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);
}
} 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());
// Collect all media sources from all movies and deduplicate by path
let mut all_sources: Vec<MediaSource> = Vec::new();
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());
}
}
}
}
}
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<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();
if let Some(best_idx) = selector::select_best_source(media_sources) {