feat: add movies support
This commit is contained in:
+29
-3
@@ -1,17 +1,43 @@
|
||||
use crate::models::Episode;
|
||||
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(ep))
|
||||
.filter(|ep| has_multiple_versions_episode(ep))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn has_multiple_versions(episode: &Episode) -> bool {
|
||||
fn has_multiple_versions_episode(episode: &Episode) -> bool {
|
||||
if let Some(media_sources) = &episode.media_sources {
|
||||
media_sources.len() > 1
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Filters movies to return only those with duplicate titles (same name and year)
|
||||
pub fn filter_duplicate_movies(movies: Vec<Movie>) -> Vec<Vec<Movie>> {
|
||||
let mut movie_map: HashMap<String, Vec<Movie>> = HashMap::new();
|
||||
|
||||
// Group movies by title and year
|
||||
for movie in movies {
|
||||
let key = format!("{}-{}", movie.name, movie.year.unwrap_or(0));
|
||||
movie_map.entry(key).or_insert_with(Vec::new).push(movie);
|
||||
}
|
||||
|
||||
// Return only groups with multiple movies or movies with multiple media sources
|
||||
movie_map
|
||||
.into_values()
|
||||
.filter(|group| group.len() > 1 || (group.len() == 1 && has_multiple_versions_movie(&group[0])))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn has_multiple_versions_movie(movie: &Movie) -> bool {
|
||||
if let Some(media_sources) = &movie.media_sources {
|
||||
media_sources.len() > 1
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -1,4 +1,4 @@
|
||||
use crate::models::{Episode, EpisodesResponse, Item, ItemsResponse};
|
||||
use crate::models::{Episode, EpisodesResponse, Item, ItemsResponse, Movie, MoviesResponse};
|
||||
use std::error::Error;
|
||||
|
||||
pub struct JellyfinClient {
|
||||
@@ -39,4 +39,16 @@ impl JellyfinClient {
|
||||
|
||||
Ok(episodes_response.items)
|
||||
}
|
||||
|
||||
pub async fn get_all_movies(&self) -> Result<Vec<Movie>, Box<dyn Error>> {
|
||||
let url = format!(
|
||||
"{}/Items?IncludeItemTypes=Movie&Recursive=true&Fields=Path,MediaSources,ProductionYear&api_key={}",
|
||||
self.base_url, self.api_key
|
||||
);
|
||||
|
||||
let response = self.client.get(&url).send().await?;
|
||||
let movies_response: MoviesResponse = response.json().await?;
|
||||
|
||||
Ok(movies_response.items)
|
||||
}
|
||||
}
|
||||
|
||||
+88
-1
@@ -1,4 +1,4 @@
|
||||
use crate::models::{Episode, MediaSource};
|
||||
use crate::models::{Episode, MediaSource, Movie};
|
||||
use crate::selector;
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
|
||||
@@ -134,3 +134,90 @@ fn format_codec(source: &MediaSource) -> String {
|
||||
})
|
||||
.unwrap_or_else(|| "Unknown".to_string())
|
||||
}
|
||||
|
||||
pub fn print_duplicate_movies(movie_groups: Vec<Vec<Movie>>) -> Vec<FileToDelete> {
|
||||
let mut files_to_delete = Vec::new();
|
||||
|
||||
for movie_group in movie_groups {
|
||||
if movie_group.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If there's only one movie in the group, it must have multiple media sources
|
||||
if movie_group.len() == 1 {
|
||||
let movie = &movie_group[0];
|
||||
println!("\n🎬 Movie: {}", format_movie_title(movie));
|
||||
println!("{}", "-".repeat(80));
|
||||
|
||||
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);
|
||||
files_to_delete.extend(to_delete);
|
||||
}
|
||||
} else {
|
||||
// Multiple movies with same name/year - treat each as a separate version
|
||||
let first_movie = &movie_group[0];
|
||||
println!("\n🎬 Movie: {}", format_movie_title(first_movie));
|
||||
println!("{}", "-".repeat(80));
|
||||
println!(" Multiple copies found: {}\n", movie_group.len());
|
||||
|
||||
// Collect all media sources from all movies
|
||||
let mut all_sources: Vec<MediaSource> = Vec::new();
|
||||
for movie in &movie_group {
|
||||
if let Some(media_sources) = &movie.media_sources {
|
||||
for source in media_sources {
|
||||
all_sources.push(source.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !all_sources.is_empty() {
|
||||
let to_delete = print_movie_versions(&first_movie.name, &all_sources);
|
||||
files_to_delete.extend(to_delete);
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", "=".repeat(80));
|
||||
}
|
||||
|
||||
files_to_delete
|
||||
}
|
||||
|
||||
fn format_movie_title(movie: &Movie) -> String {
|
||||
if let Some(year) = movie.year {
|
||||
format!("{} ({})", movie.name, year)
|
||||
} else {
|
||||
movie.name.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn print_movie_versions(_movie_name: &str, media_sources: &Vec<MediaSource>) -> Vec<FileToDelete> {
|
||||
let mut files_to_delete = Vec::new();
|
||||
|
||||
if let Some(best_idx) = selector::select_best_source(media_sources) {
|
||||
// Print selected file
|
||||
println!(" [SELECTED]");
|
||||
print_media_source(&media_sources[best_idx]);
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
files_to_delete
|
||||
}
|
||||
|
||||
+90
-24
@@ -4,28 +4,42 @@ mod display;
|
||||
mod models;
|
||||
mod selector;
|
||||
|
||||
use clap::Parser;
|
||||
use clap::{Parser, ValueEnum};
|
||||
use client::JellyfinClient;
|
||||
use display::FileToDelete;
|
||||
use std::collections::HashSet;
|
||||
use std::error::Error;
|
||||
|
||||
/// A tool to find and manage duplicate episodes in Jellyfin
|
||||
#[derive(Debug, Clone, ValueEnum)]
|
||||
enum MediaType {
|
||||
/// Process TV shows only
|
||||
Tv,
|
||||
/// Process movies only
|
||||
Movies,
|
||||
/// Process both TV shows and movies
|
||||
Both,
|
||||
}
|
||||
|
||||
/// A tool to find and manage duplicate episodes and movies in Jellyfin
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "jelly-dedup")]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct Args {
|
||||
/// Jellyfin server URL
|
||||
#[arg(short, long, env("JELLYFIN_URL"), default_value = "http://localhost:8096")]
|
||||
#[arg(short, long, env = "JELLYFIN_URL", default_value = "http://localhost:8096")]
|
||||
jellyfin_url: String,
|
||||
|
||||
/// Jellyfin API key
|
||||
#[arg(short, long, env("JELLYFIN_API_KEY"))]
|
||||
#[arg(short, long, env = "JELLYFIN_API_KEY")]
|
||||
api_key: String,
|
||||
|
||||
/// Path prefix to remove from displayed file paths
|
||||
#[arg(short, long, env("PATH_PREFIX_TO_REMOVE"))]
|
||||
#[arg(short, long, env = "PATH_PREFIX_TO_REMOVE")]
|
||||
path_prefix_to_remove: Option<String>,
|
||||
|
||||
/// Type of media to process
|
||||
#[arg(short = 't', long, value_enum, default_value = "both")]
|
||||
media_type: MediaType,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -34,44 +48,63 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
let client = JellyfinClient::new(args.jellyfin_url, args.api_key);
|
||||
|
||||
let config = Config {
|
||||
jellyfin_url: args.jellyfin_url,
|
||||
api_key: args.api_key,
|
||||
path_prefix_to_remove: args.path_prefix_to_remove,
|
||||
media_type: args.media_type,
|
||||
};
|
||||
|
||||
let client = JellyfinClient::new(config.jellyfin_url, config.api_key);
|
||||
|
||||
process_all_shows(&client, config.path_prefix_to_remove).await?;
|
||||
process_media(&client, &config).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Config {
|
||||
jellyfin_url: String,
|
||||
api_key: String,
|
||||
path_prefix_to_remove: Option<String>,
|
||||
media_type: MediaType,
|
||||
}
|
||||
|
||||
struct Statistics {
|
||||
total_duplicate_episodes: usize,
|
||||
total_duplicate_movies: usize,
|
||||
total_duplicate_files: usize,
|
||||
files_to_delete: HashSet<FileToDelete>,
|
||||
}
|
||||
|
||||
async fn process_all_shows(client: &JellyfinClient, path_prefix_to_remove: Option<String>) -> Result<(), Box<dyn Error>> {
|
||||
async fn process_media(client: &JellyfinClient, config: &Config) -> Result<(), Box<dyn Error>> {
|
||||
let mut stats = Statistics {
|
||||
total_duplicate_episodes: 0,
|
||||
total_duplicate_movies: 0,
|
||||
total_duplicate_files: 0,
|
||||
files_to_delete: HashSet::new(),
|
||||
};
|
||||
|
||||
match config.media_type {
|
||||
MediaType::Tv => {
|
||||
process_all_shows(client, &mut stats).await?;
|
||||
}
|
||||
MediaType::Movies => {
|
||||
process_all_movies(client, &mut stats).await?;
|
||||
}
|
||||
MediaType::Both => {
|
||||
process_all_shows(client, &mut stats).await?;
|
||||
process_all_movies(client, &mut stats).await?;
|
||||
}
|
||||
}
|
||||
|
||||
print_summary(&stats, config.path_prefix_to_remove.as_deref(), &config.media_type);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn process_all_shows(client: &JellyfinClient, stats: &mut Statistics) -> Result<(), Box<dyn Error>> {
|
||||
println!("Fetching all TV shows from Jellyfin...\n");
|
||||
let shows = client.get_all_shows().await?;
|
||||
|
||||
println!("Found {} TV shows\n", shows.len());
|
||||
println!("{}", "=".repeat(80));
|
||||
|
||||
let mut stats = Statistics {
|
||||
total_duplicate_episodes: 0,
|
||||
total_duplicate_files: 0,
|
||||
files_to_delete: HashSet::new(),
|
||||
};
|
||||
|
||||
for show in shows {
|
||||
match process_show(client, &show).await {
|
||||
Ok((episode_count, file_count, files_to_delete)) => {
|
||||
@@ -85,7 +118,27 @@ async fn process_all_shows(client: &JellyfinClient, path_prefix_to_remove: Optio
|
||||
}
|
||||
}
|
||||
|
||||
print_summary(&stats, path_prefix_to_remove.as_deref());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn process_all_movies(client: &JellyfinClient, stats: &mut Statistics) -> Result<(), Box<dyn Error>> {
|
||||
println!("\nFetching all movies from Jellyfin...\n");
|
||||
let movies = client.get_all_movies().await?;
|
||||
|
||||
println!("Found {} movies\n", movies.len());
|
||||
println!("{}", "=".repeat(80));
|
||||
|
||||
let duplicate_movie_groups = analyzer::filter_duplicate_movies(movies);
|
||||
|
||||
if !duplicate_movie_groups.is_empty() {
|
||||
let movie_count = duplicate_movie_groups.len();
|
||||
let files_to_delete = display::print_duplicate_movies(duplicate_movie_groups);
|
||||
let file_count = files_to_delete.len();
|
||||
|
||||
stats.total_duplicate_movies += movie_count;
|
||||
stats.total_duplicate_files += file_count;
|
||||
stats.files_to_delete.extend(files_to_delete);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -110,7 +163,7 @@ async fn process_show(
|
||||
Ok((episode_count, file_count, files_to_delete))
|
||||
}
|
||||
|
||||
fn print_summary(stats: &Statistics, path_prefix_to_remove: Option<&str>) {
|
||||
fn print_summary(stats: &Statistics, path_prefix_to_remove: Option<&str>, media_type: &MediaType) {
|
||||
// Files are already deduplicated in the HashSet
|
||||
let mut sorted_files: Vec<&FileToDelete> = stats.files_to_delete.iter().collect();
|
||||
sorted_files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
@@ -121,7 +174,20 @@ fn print_summary(stats: &Statistics, path_prefix_to_remove: Option<&str>) {
|
||||
|
||||
println!("\n{}", "=".repeat(80));
|
||||
println!("Summary:");
|
||||
println!(" Total episodes with duplicates: {}", stats.total_duplicate_episodes);
|
||||
|
||||
match media_type {
|
||||
MediaType::Tv => {
|
||||
println!(" Total episodes with duplicates: {}", stats.total_duplicate_episodes);
|
||||
}
|
||||
MediaType::Movies => {
|
||||
println!(" Total movies with duplicates: {}", stats.total_duplicate_movies);
|
||||
}
|
||||
MediaType::Both => {
|
||||
println!(" Total episodes with duplicates: {}", stats.total_duplicate_episodes);
|
||||
println!(" Total movies with duplicates: {}", stats.total_duplicate_movies);
|
||||
}
|
||||
}
|
||||
|
||||
println!(" Total files to delete: {}", sorted_files.len());
|
||||
println!(" Estimated space savings: {:.2} GB", total_space_gb);
|
||||
println!("{}", "=".repeat(80));
|
||||
@@ -135,9 +201,9 @@ fn print_summary(stats: &Statistics, path_prefix_to_remove: Option<&str>) {
|
||||
} else {
|
||||
&file.path
|
||||
};
|
||||
// Properly escape the path for bash
|
||||
display_path.to_owned().insert_str(0, ".");
|
||||
let escaped_path = shell_escape::escape(display_path.into());
|
||||
println!("rm {}", escaped_path);
|
||||
println!("rm .{}", escaped_path);
|
||||
}
|
||||
println!("{}", "=".repeat(80));
|
||||
println!("Total files to delete: {}", sorted_files.len());
|
||||
|
||||
+20
-2
@@ -14,7 +14,7 @@ pub struct Item {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct MediaStream {
|
||||
#[serde(rename = "Type")]
|
||||
pub stream_type: Option<String>,
|
||||
@@ -24,7 +24,7 @@ pub struct MediaStream {
|
||||
pub codec: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct MediaSource {
|
||||
#[serde(rename = "Path")]
|
||||
pub path: Option<String>,
|
||||
@@ -57,3 +57,21 @@ pub struct Episode {
|
||||
#[serde(rename = "MediaSources")]
|
||||
pub media_sources: Option<Vec<MediaSource>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct Movie {
|
||||
#[serde(rename = "Id")]
|
||||
pub _id: String,
|
||||
#[serde(rename = "Name")]
|
||||
pub name: String,
|
||||
#[serde(rename = "ProductionYear")]
|
||||
pub year: Option<u32>,
|
||||
#[serde(rename = "MediaSources")]
|
||||
pub media_sources: Option<Vec<MediaSource>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MoviesResponse {
|
||||
#[serde(rename = "Items")]
|
||||
pub items: Vec<Movie>,
|
||||
}
|
||||
|
||||
+33
-2
@@ -26,8 +26,8 @@ pub fn select_best_source(sources: &[MediaSource]) -> Option<usize> {
|
||||
}
|
||||
|
||||
fn is_better_source(candidate: &MediaSource, current_best: &MediaSource) -> bool {
|
||||
let candidate_height = get_height(candidate);
|
||||
let best_height = get_height(current_best);
|
||||
let candidate_height = normalize_height(get_height(candidate));
|
||||
let best_height = normalize_height(get_height(current_best));
|
||||
|
||||
// Higher resolution always wins
|
||||
if candidate_height > best_height {
|
||||
@@ -43,6 +43,37 @@ fn is_better_source(candidate: &MediaSource, current_best: &MediaSource) -> bool
|
||||
candidate_effective_bitrate > best_effective_bitrate
|
||||
}
|
||||
|
||||
fn normalize_height(height: i32) -> i32 {
|
||||
// Normalize common cropped resolutions to their standard equivalents
|
||||
|
||||
// 4K/UHD range (2160p): includes 2160p, 2076p (cropped 4K), and other 4K variants
|
||||
if height >= 2000 && height <= 2160 {
|
||||
return 2160;
|
||||
}
|
||||
|
||||
// 1080p/Full HD range: includes 1080p, 1038p, 960p (cropped 1080p)
|
||||
if height >= 960 && height <= 1088 {
|
||||
return 1080;
|
||||
}
|
||||
|
||||
// 720p/HD range: includes 720p, 694p (cropped 720p)
|
||||
if height >= 690 && height <= 720 {
|
||||
return 720;
|
||||
}
|
||||
|
||||
// 576p/SD range: includes 576p, 540p
|
||||
if height >= 540 && height <= 576 {
|
||||
return 576;
|
||||
}
|
||||
|
||||
// 480p/SD range: includes 480p, 460p
|
||||
if height >= 460 && height <= 480 {
|
||||
return 480;
|
||||
}
|
||||
|
||||
height
|
||||
}
|
||||
|
||||
fn calculate_effective_bitrate(source: &MediaSource) -> f64 {
|
||||
let bitrate = source.bitrate.unwrap_or(0) as f64;
|
||||
let codec = get_codec(source);
|
||||
|
||||
Reference in New Issue
Block a user