fix: tv show matching
This commit is contained in:
Generated
+1
-1
@@ -651,7 +651,7 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||
|
||||
[[package]]
|
||||
name = "jelly-dedup"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"dotenv",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jelly-dedup"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
+35
-6
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user