feat: initial commit

This commit is contained in:
Lucas Oskorep
2025-10-10 14:13:41 -04:00
commit 39a10ace21
11 changed files with 3172 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
use crate::models::Episode;
/// 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))
.collect()
}
fn has_multiple_versions(episode: &Episode) -> bool {
if let Some(media_sources) = &episode.media_sources {
media_sources.len() > 1
} else {
false
}
}
+42
View File
@@ -0,0 +1,42 @@
use crate::models::{Episode, EpisodesResponse, Item, ItemsResponse};
use std::error::Error;
pub struct JellyfinClient {
base_url: String,
api_key: String,
client: reqwest::Client,
}
impl JellyfinClient {
pub fn new(base_url: String, api_key: String) -> Self {
Self {
base_url,
api_key,
client: reqwest::Client::new(),
}
}
pub async fn get_all_shows(&self) -> Result<Vec<Item>, Box<dyn Error>> {
let url = format!(
"{}/Items?IncludeItemTypes=Series&Recursive=true&api_key={}",
self.base_url, self.api_key
);
let response = self.client.get(&url).send().await?;
let items_response: ItemsResponse = response.json().await?;
Ok(items_response.items)
}
pub async fn get_episodes_for_show(&self, show_id: &str) -> Result<Vec<Episode>, Box<dyn Error>> {
let url = format!(
"{}/Shows/{}/Episodes?Fields=Path,MediaSources&api_key={}",
self.base_url, show_id, self.api_key
);
let response = self.client.get(&url).send().await?;
let episodes_response: EpisodesResponse = response.json().await?;
Ok(episodes_response.items)
}
}
+136
View File
@@ -0,0 +1,136 @@
use crate::models::{Episode, MediaSource};
use crate::selector;
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct FileToDelete {
pub path: String,
pub size: i64,
}
pub fn print_duplicate_episodes(show_name: &str, episodes: Vec<Episode>) -> Vec<FileToDelete> {
println!("\n📺 Show: {}", show_name);
println!("{}", "-".repeat(80));
println!(" Episodes with multiple versions: {}\n", episodes.len());
let mut files_to_delete = Vec::new();
for episode in episodes {
let to_delete = print_episode_with_versions(episode);
files_to_delete.extend(to_delete);
}
println!("{}", "=".repeat(80));
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);
let version_count = episode
.media_sources
.as_ref()
.map(|ms| ms.len())
.unwrap_or(0);
println!(
" S{:02}E{:02} - {} ({} versions)",
season, ep_num, episode.name, version_count
);
let mut files_to_delete = Vec::new();
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]);
// 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
}
fn print_media_source(source: &MediaSource) {
let bitrate_str = format_bitrate(source.bitrate);
let resolution_str = format_resolution(source);
let size_str = format_size(source.size);
let codec_str = format_codec(source);
let container_str = format_container(&source.container);
let path_str = format_path(&source.path);
println!(
" {} | {} | {} | {} | {} | {}",
bitrate_str, resolution_str, size_str, codec_str, container_str, path_str
);
}
fn format_bitrate(bitrate: Option<i64>) -> String {
bitrate
.map(|b| format!("{:.2} Mbps", b as f64 / 1_000_000.0))
.unwrap_or_else(|| "Unknown".to_string())
}
fn format_resolution(source: &MediaSource) -> String {
source
.height
.map(|h| format!("{}p", h))
.or_else(|| {
// Look for resolution in MediaStreams (video stream)
source.media_streams.as_ref().and_then(|streams| {
streams
.iter()
.find(|s| s.stream_type.as_deref() == Some("Video"))
.and_then(|s| s.height.map(|h| format!("{}p", h)))
})
})
.unwrap_or_else(|| "Unknown".to_string())
}
fn format_container(container: &Option<String>) -> &str {
container.as_ref().map(|s| s.as_str()).unwrap_or("Unknown")
}
fn format_path(path: &Option<String>) -> &str {
path.as_ref().map(|s| s.as_str()).unwrap_or("Unknown")
}
fn format_size(size: Option<i64>) -> String {
size.map(|s| format!("{:.2} GB", s as f64 / 1_073_741_824.0))
.unwrap_or_else(|| "Unknown".to_string())
}
fn format_codec(source: &MediaSource) -> String {
// Look for video codec in MediaStreams
source
.media_streams
.as_ref()
.and_then(|streams| {
streams
.iter()
.find(|s| s.stream_type.as_deref() == Some("Video"))
.and_then(|s| s.codec.as_ref().map(|c| c.to_string()))
})
.unwrap_or_else(|| "Unknown".to_string())
}
+147
View File
@@ -0,0 +1,147 @@
mod analyzer;
mod client;
mod display;
mod models;
mod selector;
use clap::Parser;
use client::JellyfinClient;
use display::FileToDelete;
use std::collections::HashSet;
use std::env;
use std::error::Error;
/// A tool to find and manage duplicate episodes 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")]
jellyfin_url: String,
/// 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")]
path_prefix_to_remove: Option<String>,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
dotenv::dotenv().ok();
let args = Args::parse();
let config = Config {
jellyfin_url: args.jellyfin_url,
api_key: args.api_key,
path_prefix_to_remove: args.path_prefix_to_remove,
};
let client = JellyfinClient::new(config.jellyfin_url, config.api_key);
process_all_shows(&client, config.path_prefix_to_remove).await?;
Ok(())
}
struct Config {
jellyfin_url: String,
api_key: String,
path_prefix_to_remove: Option<String>,
}
struct Statistics {
total_duplicate_episodes: 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>> {
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)) => {
stats.total_duplicate_episodes += episode_count;
stats.total_duplicate_files += file_count;
stats.files_to_delete.extend(files_to_delete);
}
Err(e) => {
eprintln!(" ❌ Error processing {}: {}", show.name, e);
}
}
}
print_summary(&stats, path_prefix_to_remove.as_deref());
Ok(())
}
async fn process_show(
client: &JellyfinClient,
show: &models::Item,
) -> Result<(usize, usize, Vec<FileToDelete>), Box<dyn Error>> {
let episodes = client.get_episodes_for_show(&show.id).await?;
let duplicate_episodes = analyzer::filter_duplicate_episodes(episodes);
let episode_count = duplicate_episodes.len();
let files_to_delete = if !duplicate_episodes.is_empty() {
display::print_duplicate_episodes(&show.name, duplicate_episodes)
} else {
Vec::new()
};
let file_count = files_to_delete.len();
Ok((episode_count, file_count, files_to_delete))
}
fn print_summary(stats: &Statistics, path_prefix_to_remove: Option<&str>) {
// 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));
// Calculate total space to be freed
let total_space_bytes: i64 = stats.files_to_delete.iter().map(|f| f.size).sum();
let total_space_gb = total_space_bytes as f64 / 1_073_741_824.0;
println!("\n{}", "=".repeat(80));
println!("Summary:");
println!(" Total episodes with duplicates: {}", stats.total_duplicate_episodes);
println!(" Total files to delete: {}", sorted_files.len());
println!(" Estimated space savings: {:.2} GB", total_space_gb);
println!("{}", "=".repeat(80));
if !sorted_files.is_empty() {
println!("\nFiles marked for deletion:");
println!("{}", "=".repeat(80));
for file in &sorted_files {
let display_path = if let Some(prefix) = path_prefix_to_remove {
file.path.strip_prefix(prefix).unwrap_or(&file.path)
} else {
&file.path
};
// Properly escape the path for bash
let escaped_path = shell_escape::escape(display_path.into());
println!("rm {}", escaped_path);
}
println!("{}", "=".repeat(80));
println!("Total files to delete: {}", sorted_files.len());
println!("Total space to free: {:.2} GB", total_space_gb);
}
}
+59
View File
@@ -0,0 +1,59 @@
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct ItemsResponse {
#[serde(rename = "Items")]
pub items: Vec<Item>,
}
#[derive(Debug, Deserialize)]
pub struct Item {
#[serde(rename = "Id")]
pub id: String,
#[serde(rename = "Name")]
pub name: String,
}
#[derive(Debug, Deserialize)]
pub struct MediaStream {
#[serde(rename = "Type")]
pub stream_type: Option<String>,
#[serde(rename = "Height")]
pub height: Option<i32>,
#[serde(rename = "Codec")]
pub codec: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct MediaSource {
#[serde(rename = "Path")]
pub path: Option<String>,
#[serde(rename = "Container")]
pub container: Option<String>,
#[serde(rename = "Size")]
pub size: Option<i64>,
#[serde(rename = "Bitrate")]
pub bitrate: Option<i64>,
#[serde(rename = "Height")]
pub height: Option<i32>,
#[serde(rename = "MediaStreams")]
pub media_streams: Option<Vec<MediaStream>>,
}
#[derive(Debug, Deserialize)]
pub struct EpisodesResponse {
#[serde(rename = "Items")]
pub items: Vec<Episode>,
}
#[derive(Debug, Deserialize)]
pub struct Episode {
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "IndexNumber")]
pub episode_number: Option<u32>,
#[serde(rename = "ParentIndexNumber")]
pub season_number: Option<u32>,
#[serde(rename = "MediaSources")]
pub media_sources: Option<Vec<MediaSource>>,
}
+91
View File
@@ -0,0 +1,91 @@
use crate::models::MediaSource;
/// 1. Resolution First: Higher resolution always wins (e.g., 1080p beats 720p)
// 2. Quality-Optimized Codec Comparison: At the same resolution, it calculates an "effective bitrate" that accounts for codec efficiency:
// - H.265/HEVC: 1.5x multiplier (50% more efficient than H.264)
// - AV1: 2.0x multiplier (50% more efficient than H.264, ~30% better than H.265)
// - H.264: 1.0x baseline
// 3. Example Scenarios:
// - File A: 1080p H.264 @ 8 Mbps = 8.0 effective bitrate
// - File B: 1080p H.265 @ 6 Mbps = 9.0 effective bitrate (6 × 1.5) → File B selected
// - File C: 1080p H.265 @ 4 Mbps = 6.0 effective bitrate → File A selected
pub fn select_best_source(sources: &[MediaSource]) -> Option<usize> {
if sources.is_empty() {
return None;
}
let mut best_idx = 0;
for (idx, source) in sources.iter().enumerate().skip(1) {
if is_better_source(source, &sources[best_idx]) {
best_idx = idx;
}
}
Some(best_idx)
}
fn is_better_source(candidate: &MediaSource, current_best: &MediaSource) -> bool {
let candidate_height = get_height(candidate);
let best_height = get_height(current_best);
// Higher resolution always wins
if candidate_height > best_height {
return true;
}
if candidate_height < best_height {
return false;
}
let candidate_effective_bitrate = calculate_effective_bitrate(candidate);
let best_effective_bitrate = calculate_effective_bitrate(current_best);
candidate_effective_bitrate > best_effective_bitrate
}
fn calculate_effective_bitrate(source: &MediaSource) -> f64 {
let bitrate = source.bitrate.unwrap_or(0) as f64;
let codec = get_codec(source);
let efficiency_multiplier = get_codec_efficiency_multiplier(&codec);
bitrate * efficiency_multiplier
}
fn get_codec_efficiency_multiplier(codec: &str) -> f64 {
let codec_lower = codec.to_lowercase();
if codec_lower.contains("av1") {
// AV1 is ~50% more efficient than H.264, or ~30% better than H.265
// So same bitrate AV1 ≈ 2.0x the quality of H.264
2.0
} else if codec_lower.contains("hevc") || codec_lower.contains("h265") {
// H.265 is ~50% more efficient than H.264
// So same bitrate H.265 ≈ 1.5x the quality of H.264
1.5
} else {
1.0
}
}
fn get_height(source: &MediaSource) -> i32 {
source.height.or_else(|| {
source.media_streams.as_ref().and_then(|streams| {
streams
.iter()
.find(|s| s.stream_type.as_deref() == Some("Video"))
.and_then(|s| s.height)
})
}).unwrap_or(0)
}
fn get_codec(source: &MediaSource) -> String {
source.media_streams
.as_ref()
.and_then(|streams| {
streams
.iter()
.find(|s| s.stream_type.as_deref() == Some("Video"))
.and_then(|s| s.codec.clone())
})
.unwrap_or_default()
}