fix: tv show matching
This commit is contained in:
Generated
+1
-1
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
+42
-55
@@ -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 idx != best_idx {
|
|
||||||
print_media_source(source);
|
|
||||||
if let Some(path) = &source.path {
|
if let Some(path) = &source.path {
|
||||||
let size = source.size.unwrap_or(0);
|
if seen_paths.insert(path.clone()) {
|
||||||
files_to_delete.push(FileToDelete {
|
all_sources.push(source.clone());
|
||||||
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user