feat: initial commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>>,
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user