mod config;
use std::fs::File;
use std::io::{BufReader, BufWriter, Write};
use std::num::NonZeroU32;
use std::path::PathBuf;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use clap::{Parser, Subcommand};
use governor::{Quota, RateLimiter};
use indicatif::{ProgressBar, ProgressStyle};
use serde::{Deserialize, Serialize};
use immich_lib::models::ExecutionConfig;
use immich_lib::testing::{all_fixtures, detect_scenarios, format_report, generate_image, ScenarioReport};
use immich_lib::{DuplicateAnalysis, Executor, ImmichClient, LetterboxAnalysis};
#[derive(Parser, Debug)]
#[command(name = "immich-dupes")]
#[command(version, about, long_about = None)]
struct Args {
#[arg(short, long, env = "IMMICH_URL", required = false)]
url: Option<String>,
#[arg(short, long, env = "IMMICH_API_KEY", required = false)]
api_key: Option<String>,
#[arg(long, global = true)]
save: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
Analyze {
#[arg(short, long)]
output: PathBuf,
},
Execute {
#[arg(short, long)]
input: PathBuf,
#[arg(short, long)]
backup_dir: PathBuf,
#[arg(long, default_value = "false")]
force: bool,
#[arg(long, default_value = "10")]
rate_limit: u32,
#[arg(long, default_value = "5")]
concurrent: usize,
#[arg(long, default_value = "false")]
skip_review: bool,
#[arg(short, long, default_value = "false")]
yes: bool,
},
Verify {
analysis_json: PathBuf,
#[arg(long, default_value = "text")]
format: String,
},
FindTestCandidates {
#[arg(long, default_value = "text")]
format: String,
#[arg(long)]
scenario: Option<String>,
#[arg(short, long)]
output: Option<PathBuf>,
},
GenerateFixtures {
#[arg(long, default_value = "tests/fixtures")]
output_dir: PathBuf,
#[arg(long)]
scenario: Option<String>,
},
Restore {
#[arg(short, long)]
backup_dir: PathBuf,
#[arg(long, default_value = "false")]
dry_run: bool,
},
Letterbox {
#[command(subcommand)]
command: LetterboxCommands,
},
}
#[derive(Subcommand, Debug)]
enum LetterboxCommands {
Analyze {
#[arg(short, long)]
output: PathBuf,
},
Execute {
#[arg(short, long)]
input: PathBuf,
#[arg(short, long)]
backup_dir: PathBuf,
#[arg(long, default_value = "false")]
force: bool,
#[arg(long, default_value = "10")]
rate_limit: u32,
#[arg(short, long, default_value = "false")]
yes: bool,
},
Verify {
analysis_json: PathBuf,
#[arg(long, default_value = "text")]
format: String,
},
}
#[derive(Debug, Serialize, Deserialize)]
struct AnalysisReport {
generated_at: DateTime<Utc>,
server_url: String,
total_groups: usize,
total_assets: usize,
needs_review_count: usize,
groups: Vec<DuplicateAnalysis>,
}
#[derive(Debug, Serialize)]
struct GroupVerification {
duplicate_id: String,
winner_status: AssetStatus,
loser_statuses: Vec<AssetStatus>,
consolidation_checks: Vec<ConsolidationCheck>,
}
#[derive(Debug, Serialize)]
struct AssetStatus {
asset_id: String,
filename: String,
status: String,
error: Option<String>,
}
#[derive(Debug, Serialize)]
struct ConsolidationCheck {
check_type: String,
passed: bool,
details: String,
}
#[derive(Debug, Serialize)]
struct VerificationReport {
verified_at: DateTime<Utc>,
server_url: String,
groups_verified: usize,
winners_present: usize,
winners_missing: usize,
losers_deleted: usize,
losers_still_present: usize,
consolidation_passed: usize,
consolidation_failed: usize,
groups: Vec<GroupVerification>,
anomalies: Vec<String>,
}
fn resolve_credentials(
cli_url: Option<&str>,
cli_api_key: Option<&str>,
config: &config::Config,
) -> Result<(String, String, bool)> {
if let (Some(url), Some(key)) = (cli_url, cli_api_key) {
return Ok((url.to_string(), key.to_string(), false));
}
if let (Some(url), Some(key)) = (&config.server.url, &config.server.api_key) {
return Ok((url.clone(), key.clone(), false));
}
let (url, key) = config::prompt_credentials()?;
Ok((url, key, true))
}
fn maybe_save_credentials(
url: &str,
api_key: &str,
was_prompted: bool,
save_flag: bool,
config: &config::Config,
) -> Result<bool> {
if !was_prompted && !save_flag {
return Ok(false);
}
let config_path = config::config_path();
if config.server.url.as_deref() == Some(url)
&& config.server.api_key.as_deref() == Some(api_key)
{
return Ok(false);
}
if config::prompt_save(&config_path) {
let mut new_config = config.clone();
new_config.server.url = Some(url.to_string());
new_config.server.api_key = Some(api_key.to_string());
config::save(&new_config)?;
println!("Credentials saved to {}", config_path.display());
return Ok(true);
}
Ok(false)
}
#[tokio::main]
async fn main() -> Result<()> {
let _ = dotenvy::dotenv();
let config = config::load();
let args = Args::parse();
match args.command {
Commands::Analyze { output } => {
let (url, api_key, prompted) = resolve_credentials(
args.url.as_deref(),
args.api_key.as_deref(),
&config,
)?;
run_analyze(&url, &api_key, &output).await?;
maybe_save_credentials(&url, &api_key, prompted, args.save, &config)?;
}
Commands::Execute {
input,
backup_dir,
force,
rate_limit,
concurrent,
skip_review,
yes,
} => {
let (url, api_key, prompted) = resolve_credentials(
args.url.as_deref(),
args.api_key.as_deref(),
&config,
)?;
run_execute(
&url,
&api_key,
&input,
&backup_dir,
force,
rate_limit,
concurrent,
skip_review,
yes,
)
.await?;
maybe_save_credentials(&url, &api_key, prompted, args.save, &config)?;
}
Commands::Verify { analysis_json, format } => {
let (url, api_key, prompted) = resolve_credentials(
args.url.as_deref(),
args.api_key.as_deref(),
&config,
)?;
run_verify(&url, &api_key, &analysis_json, &format).await?;
maybe_save_credentials(&url, &api_key, prompted, args.save, &config)?;
}
Commands::FindTestCandidates {
format,
scenario,
output,
} => {
let (url, api_key, prompted) = resolve_credentials(
args.url.as_deref(),
args.api_key.as_deref(),
&config,
)?;
run_find_test_candidates(&url, &api_key, &format, scenario.as_deref(), output.as_ref())
.await?;
maybe_save_credentials(&url, &api_key, prompted, args.save, &config)?;
}
Commands::GenerateFixtures { output_dir, scenario } => {
run_generate_fixtures(&output_dir, scenario.as_deref())?;
}
Commands::Restore { backup_dir, dry_run } => {
let (url, api_key, prompted) = resolve_credentials(
args.url.as_deref(),
args.api_key.as_deref(),
&config,
)?;
run_restore(&url, &api_key, &backup_dir, dry_run).await?;
maybe_save_credentials(&url, &api_key, prompted, args.save, &config)?;
}
Commands::Letterbox { command } => {
let (url, api_key, prompted) = resolve_credentials(
args.url.as_deref(),
args.api_key.as_deref(),
&config,
)?;
match command {
LetterboxCommands::Analyze { output } => {
run_letterbox_analyze(&url, &api_key, &output).await?;
}
LetterboxCommands::Execute {
input,
backup_dir,
force,
rate_limit,
yes,
} => {
run_letterbox_execute(&url, &api_key, &input, &backup_dir, force, rate_limit, yes).await?;
}
LetterboxCommands::Verify { analysis_json, format } => {
run_letterbox_verify(&url, &api_key, &analysis_json, &format).await?;
}
}
maybe_save_credentials(&url, &api_key, prompted, args.save, &config)?;
}
}
Ok(())
}
async fn run_analyze(url: &str, api_key: &str, output: &PathBuf) -> Result<()> {
println!("Connecting to Immich server at {}...", url);
let client =
ImmichClient::new(url, api_key).context("Failed to create Immich client")?;
println!("Fetching duplicate groups...");
let duplicates = client
.get_duplicates()
.await
.context("Failed to fetch duplicates from Immich")?;
println!("Analyzing {} duplicate groups...", duplicates.len());
let groups: Vec<DuplicateAnalysis> = duplicates
.iter()
.map(DuplicateAnalysis::from_group)
.collect();
let total_groups = groups.len();
let total_assets: usize = groups
.iter()
.map(|g| 1 + g.losers.len()) .sum();
let needs_review_count = groups.iter().filter(|g| g.needs_review).count();
let report = AnalysisReport {
generated_at: Utc::now(),
server_url: url.to_string(),
total_groups,
total_assets,
needs_review_count,
groups,
};
let file = File::create(output)
.with_context(|| format!("Failed to create output file: {}", output.display()))?;
let writer = BufWriter::new(file);
serde_json::to_writer_pretty(writer, &report)
.context("Failed to write JSON output")?;
println!();
println!("Analysis complete!");
println!();
println!("Duplicate groups: {}", total_groups);
println!("Total assets: {}", total_assets);
if needs_review_count > 0 {
println!(
"Groups needing review: {} (have metadata conflicts)",
needs_review_count
);
} else {
println!("Groups needing review: 0");
}
println!();
println!("Output written to: {}", output.display());
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn run_execute(
url: &str,
api_key: &str,
input: &PathBuf,
backup_dir: &PathBuf,
force: bool,
rate_limit: u32,
concurrent: usize,
skip_review: bool,
yes: bool,
) -> Result<()> {
let file = File::open(input)
.with_context(|| format!("Failed to open input file: {}", input.display()))?;
let reader = BufReader::new(file);
let report: AnalysisReport = serde_json::from_reader(reader)
.context("Failed to parse analysis JSON")?;
let groups: Vec<DuplicateAnalysis> = if skip_review {
report.groups.into_iter().filter(|g| !g.needs_review).collect()
} else {
report.groups
};
if groups.is_empty() {
println!("No groups to process.");
return Ok(());
}
let total_assets: usize = groups.iter().map(|g| g.losers.len()).sum();
let estimated_size: u64 = groups
.iter()
.flat_map(|g| g.losers.iter())
.filter_map(|l| l.file_size)
.sum();
std::fs::create_dir_all(backup_dir)
.with_context(|| format!("Failed to create backup directory: {}", backup_dir.display()))?;
println!();
println!("Execution Plan");
println!("==============");
println!("Groups to process: {}", groups.len());
println!("Assets to download: {}", total_assets);
if estimated_size > 0 {
let size_mb = estimated_size as f64 / 1_048_576.0;
println!("Estimated disk space: {:.1} MB", size_mb);
}
println!("Backup directory: {}", backup_dir.display());
println!("Force delete: {}", if force { "yes (permanent)" } else { "no (trash)" });
println!();
if !yes {
print!("About to download {} assets and delete them from Immich. Continue? [y/N] ", total_assets);
std::io::stdout().flush()?;
let mut response = String::new();
std::io::stdin().read_line(&mut response)?;
let response = response.trim().to_lowercase();
if response != "y" && response != "yes" {
println!("Aborted.");
return Ok(());
}
}
println!();
println!("Starting execution...");
println!();
let client = ImmichClient::new(url, api_key)
.context("Failed to create Immich client")?;
let config = ExecutionConfig {
requests_per_sec: rate_limit,
max_concurrent: concurrent,
backup_dir: backup_dir.clone(),
force_delete: force,
};
let executor = Executor::new(client, config);
let exec_report = executor.execute_all(&groups).await;
println!();
println!("Execution Complete");
println!("==================");
println!("Groups processed: {}", exec_report.total_groups);
println!("Assets downloaded: {}", exec_report.downloaded);
println!("Assets deleted: {}", exec_report.deleted);
println!("Failed operations: {}", exec_report.failed);
println!("Skipped: {}", exec_report.skipped);
if exec_report.failed > 0 {
println!();
println!("First errors:");
let errors: Vec<_> = exec_report
.results
.iter()
.flat_map(|g| g.download_results.iter())
.filter_map(|r| {
if let immich_lib::models::OperationResult::Failed { id, error } = r {
Some((id, error))
} else {
None
}
})
.take(5)
.collect();
for (id, error) in errors {
println!(" - {}: {}", id, error);
}
}
let timestamp = Utc::now().format("%Y%m%d-%H%M%S");
let report_path = backup_dir.join(format!("execution-report-{}.json", timestamp));
let report_file = File::create(&report_path)
.with_context(|| format!("Failed to create report file: {}", report_path.display()))?;
let writer = BufWriter::new(report_file);
serde_json::to_writer_pretty(writer, &exec_report)
.context("Failed to write execution report")?;
println!();
println!("Execution report: {}", report_path.display());
Ok(())
}
async fn run_verify(url: &str, api_key: &str, analysis_json: &PathBuf, format: &str) -> Result<()> {
println!("Verifying post-execution state...");
println!("Analysis file: {}", analysis_json.display());
println!();
let file = File::open(analysis_json)
.with_context(|| format!("Failed to open analysis file: {}", analysis_json.display()))?;
let reader = BufReader::new(file);
let analysis: AnalysisReport = serde_json::from_reader(reader)
.context("Failed to parse analysis JSON")?;
let client = ImmichClient::new(url, api_key).context("Failed to create Immich client")?;
let mut groups_verified = 0;
let mut winners_present = 0;
let mut winners_missing = 0;
let mut losers_deleted = 0;
let mut losers_still_present = 0;
let mut consolidation_passed = 0;
let mut consolidation_failed = 0;
let mut group_results = Vec::new();
let mut anomalies = Vec::new();
println!("Checking {} groups...", analysis.groups.len());
println!();
for group in &analysis.groups {
groups_verified += 1;
let winner_status = match client.get_asset(&group.winner.asset_id).await {
Ok(asset) => {
winners_present += 1;
let mut consolidation_checks = Vec::new();
let winner_had_gps = group.winner.score.gps > 0;
let any_loser_had_gps = group.losers.iter().any(|l| l.score.gps > 0);
if !winner_had_gps && any_loser_had_gps {
let has_gps_now = asset.exif_info.as_ref().is_some_and(|e| e.has_gps());
if has_gps_now {
consolidation_passed += 1;
consolidation_checks.push(ConsolidationCheck {
check_type: "gps_transferred".to_string(),
passed: true,
details: "GPS coordinates successfully transferred from loser".to_string(),
});
} else {
consolidation_failed += 1;
consolidation_checks.push(ConsolidationCheck {
check_type: "gps_transferred".to_string(),
passed: false,
details: "GPS coordinates were NOT transferred from loser".to_string(),
});
anomalies.push(format!(
"Group {}: GPS not transferred to winner {}",
group.duplicate_id, group.winner.asset_id
));
}
}
AssetStatus {
asset_id: group.winner.asset_id.clone(),
filename: group.winner.filename.clone(),
status: "present".to_string(),
error: None,
}
}
Err(immich_lib::ImmichError::Api { status: 404, .. }) => {
winners_missing += 1;
anomalies.push(format!(
"CRITICAL: Winner {} ({}) was deleted!",
group.winner.asset_id, group.winner.filename
));
AssetStatus {
asset_id: group.winner.asset_id.clone(),
filename: group.winner.filename.clone(),
status: "deleted".to_string(),
error: Some("Winner was incorrectly deleted".to_string()),
}
}
Err(e) => {
winners_missing += 1;
anomalies.push(format!(
"Error checking winner {}: {}",
group.winner.asset_id, e
));
AssetStatus {
asset_id: group.winner.asset_id.clone(),
filename: group.winner.filename.clone(),
status: "error".to_string(),
error: Some(e.to_string()),
}
}
};
let mut loser_statuses = Vec::new();
for loser in &group.losers {
let loser_status = match client.get_asset(&loser.asset_id).await {
Ok(asset) => {
if asset.is_trashed {
losers_deleted += 1;
AssetStatus {
asset_id: loser.asset_id.clone(),
filename: loser.filename.clone(),
status: "trashed".to_string(),
error: None,
}
} else {
losers_still_present += 1;
anomalies.push(format!(
"Loser {} ({}) still exists (not trashed), should be deleted",
loser.asset_id, loser.filename
));
AssetStatus {
asset_id: loser.asset_id.clone(),
filename: loser.filename.clone(),
status: "present".to_string(),
error: Some("Loser should have been deleted".to_string()),
}
}
}
Err(immich_lib::ImmichError::Api { status: 404, .. }) => {
losers_deleted += 1;
AssetStatus {
asset_id: loser.asset_id.clone(),
filename: loser.filename.clone(),
status: "deleted".to_string(),
error: None,
}
}
Err(e) => {
anomalies.push(format!(
"Error checking loser {}: {}",
loser.asset_id, e
));
AssetStatus {
asset_id: loser.asset_id.clone(),
filename: loser.filename.clone(),
status: "error".to_string(),
error: Some(e.to_string()),
}
}
};
loser_statuses.push(loser_status);
}
let consolidation_checks = if winner_status.status == "present" {
let mut checks = Vec::new();
let winner_had_gps = group.winner.score.gps > 0;
let any_loser_had_gps = group.losers.iter().any(|l| l.score.gps > 0);
if !winner_had_gps && any_loser_had_gps {
} else if winner_had_gps {
checks.push(ConsolidationCheck {
check_type: "gps_retained".to_string(),
passed: true,
details: "Winner already had GPS, no transfer needed".to_string(),
});
} else {
checks.push(ConsolidationCheck {
check_type: "no_gps".to_string(),
passed: true,
details: "No GPS in group, no transfer needed".to_string(),
});
}
checks
} else {
Vec::new()
};
group_results.push(GroupVerification {
duplicate_id: group.duplicate_id.clone(),
winner_status,
loser_statuses,
consolidation_checks,
});
if groups_verified % 10 == 0 {
print!(".");
std::io::stdout().flush()?;
}
}
println!();
println!();
let report = VerificationReport {
verified_at: Utc::now(),
server_url: url.to_string(),
groups_verified,
winners_present,
winners_missing,
losers_deleted,
losers_still_present,
consolidation_passed,
consolidation_failed,
groups: group_results,
anomalies: anomalies.clone(),
};
match format.to_lowercase().as_str() {
"json" => {
println!("{}", serde_json::to_string_pretty(&report)?);
}
_ => {
println!("Verification Report");
println!("==================");
println!();
println!("Groups verified: {}", groups_verified);
println!("Winners present: {}/{}", winners_present, groups_verified);
println!("Winners missing: {}", winners_missing);
println!("Losers deleted: {}", losers_deleted);
println!("Losers still present: {}", losers_still_present);
println!();
println!("Consolidation passed: {}", consolidation_passed);
println!("Consolidation failed: {}", consolidation_failed);
if !anomalies.is_empty() {
println!();
println!("Anomalies ({}):", anomalies.len());
for anomaly in &anomalies {
println!(" - {}", anomaly);
}
}
println!();
if winners_missing == 0 && losers_still_present == 0 && consolidation_failed == 0 {
println!("VERIFICATION PASSED: All checks successful");
} else {
println!("VERIFICATION FAILED: Issues detected");
}
}
}
Ok(())
}
async fn run_find_test_candidates(
url: &str,
api_key: &str,
format: &str,
scenario_filter: Option<&str>,
output: Option<&PathBuf>,
) -> Result<()> {
println!("Connecting to Immich server at {}...", url);
let client = ImmichClient::new(url, api_key).context("Failed to create Immich client")?;
println!("Fetching duplicate groups...");
let duplicates = client
.get_duplicates()
.await
.context("Failed to fetch duplicates from Immich")?;
println!("Analyzing {} duplicate groups for test scenarios...", duplicates.len());
let mut all_matches = Vec::new();
for group in &duplicates {
let matches = detect_scenarios(group);
all_matches.extend(matches);
}
let filtered_matches = if let Some(prefix) = scenario_filter {
let prefix_upper = prefix.to_uppercase();
all_matches
.into_iter()
.filter(|m| m.scenario.to_string().to_uppercase().starts_with(&prefix_upper))
.collect()
} else {
all_matches
};
let report = ScenarioReport::from_matches(filtered_matches, duplicates.len());
let output_text = match format.to_lowercase().as_str() {
"json" => serde_json::to_string_pretty(&report)?,
_ => format_report(&report),
};
if let Some(output_path) = output {
let mut file = File::create(output_path)
.with_context(|| format!("Failed to create output file: {}", output_path.display()))?;
file.write_all(output_text.as_bytes())?;
println!("\nOutput written to: {}", output_path.display());
} else {
println!();
println!("{}", output_text);
}
Ok(())
}
#[derive(Debug, Serialize)]
struct FixtureManifest {
scenario: String,
description: String,
images: Vec<String>,
expected_winner: String,
}
fn run_generate_fixtures(output_dir: &PathBuf, scenario_filter: Option<&str>) -> Result<()> {
println!("Loading fixture definitions...");
let fixtures = all_fixtures();
let total = fixtures.len();
let base_dir = output_dir.join("base");
if !base_dir.exists() {
println!("Warning: Base images directory not found: {}", base_dir.display());
println!("Run the fixture setup first to download base images.");
}
let fixtures: Vec<_> = if let Some(filter) = scenario_filter {
let filter_upper = filter.to_uppercase();
fixtures
.into_iter()
.filter(|f| f.scenario.to_string().to_uppercase().starts_with(&filter_upper))
.collect()
} else {
fixtures
};
if fixtures.is_empty() {
if let Some(filter) = scenario_filter {
println!("No fixtures found matching filter: {}", filter);
} else {
println!("No fixtures defined.");
}
return Ok(());
}
println!(
"Generating {} of {} fixtures...",
fixtures.len(),
total
);
std::fs::create_dir_all(output_dir)
.with_context(|| format!("Failed to create output directory: {}", output_dir.display()))?;
let mut generated_count = 0;
let mut failed_count = 0;
for fixture in &fixtures {
let scenario_code = fixture.scenario.code();
let scenario_dir = output_dir.join(scenario_code);
std::fs::create_dir_all(&scenario_dir).with_context(|| {
format!(
"Failed to create scenario directory: {}",
scenario_dir.display()
)
})?;
println!(" {} - {}...", scenario_code.to_uppercase(), fixture.description);
let mut image_filenames = Vec::new();
let mut all_success = true;
for image in &fixture.images {
match generate_image(image, &base_dir, &scenario_dir) {
Ok(path) => {
image_filenames.push(image.filename.clone());
println!(" ✓ {}", path.file_name().unwrap_or_default().to_string_lossy());
}
Err(e) => {
eprintln!(" ✗ {} - {}", image.filename, e);
all_success = false;
}
}
}
let manifest = FixtureManifest {
scenario: scenario_code.to_uppercase(),
description: fixture.description.clone(),
images: image_filenames.clone(),
expected_winner: fixture
.images
.get(fixture.expected_winner_index)
.map(|i| i.filename.clone())
.unwrap_or_default(),
};
let manifest_path = scenario_dir.join("manifest.json");
let manifest_file = File::create(&manifest_path)
.with_context(|| format!("Failed to create manifest: {}", manifest_path.display()))?;
serde_json::to_writer_pretty(manifest_file, &manifest)
.context("Failed to write manifest JSON")?;
if all_success {
generated_count += 1;
} else {
failed_count += 1;
}
}
println!();
println!("Generation complete!");
println!(" Successful: {}", generated_count);
if failed_count > 0 {
println!(" Failed: {}", failed_count);
}
println!(" Output directory: {}", output_dir.display());
Ok(())
}
const MEDIA_EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "gif", "webp", "heic", "heif", "bmp", "tiff", "tif", "raw",
"mp4", "mov", "avi", "webm", "mkv", "m4v", "wmv", "flv", "3gp",
];
async fn run_restore(url: &str, api_key: &str, backup_dir: &PathBuf, dry_run: bool) -> Result<()> {
println!("Restoring from: {}", backup_dir.display());
println!();
let entries = std::fs::read_dir(backup_dir)
.with_context(|| format!("Failed to read backup directory: {}", backup_dir.display()))?;
let mut media_files: Vec<PathBuf> = Vec::new();
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
continue;
}
if let Some(ext) = path.extension().and_then(|e| e.to_str())
&& MEDIA_EXTENSIONS.contains(&ext.to_lowercase().as_str())
{
media_files.push(path);
}
}
if media_files.is_empty() {
println!("No media files found in backup directory.");
return Ok(());
}
media_files.sort();
println!("Found {} files to restore", media_files.len());
println!();
if dry_run {
println!("DRY RUN - No files will be uploaded");
println!();
for (i, path) in media_files.iter().enumerate() {
let filename = path.file_name().unwrap_or_default().to_string_lossy();
println!("[{}/{}] Would restore: {}", i + 1, media_files.len(), filename);
}
println!();
println!("Dry run complete: {} files would be restored", media_files.len());
return Ok(());
}
let client = ImmichClient::new(url, api_key).context("Failed to create Immich client")?;
let mut success_count = 0;
let mut failure_count = 0;
let total = media_files.len();
for (i, path) in media_files.iter().enumerate() {
let filename = path.file_name().unwrap_or_default().to_string_lossy();
print!("[{}/{}] Uploading {}... ", i + 1, total, filename);
std::io::stdout().flush()?;
match client.upload_asset(path).await {
Ok(response) => {
success_count += 1;
if response.duplicate {
println!("OK (duplicate detected)");
} else {
println!("OK (id: {})", response.id);
}
}
Err(e) => {
failure_count += 1;
println!("FAILED: {}", e);
}
}
}
println!();
println!("Restore complete: {} uploaded, {} failed", success_count, failure_count);
if failure_count > 0 {
println!();
println!("WARNING: {} files failed to upload. Check errors above.", failure_count);
}
Ok(())
}
async fn run_letterbox_analyze(url: &str, api_key: &str, output: &PathBuf) -> Result<()> {
println!("Connecting to Immich server at {}...", url);
let client = ImmichClient::new(url, api_key).context("Failed to create Immich client")?;
println!("Fetching all assets...");
let assets = client
.get_all_assets()
.await
.context("Failed to fetch assets from Immich")?;
println!("Analyzing {} assets for letterbox pairs...", assets.len());
let analysis = LetterboxAnalysis::from_assets(&assets);
let file = File::create(output)
.with_context(|| format!("Failed to create output file: {}", output.display()))?;
let writer = BufWriter::new(file);
serde_json::to_writer_pretty(writer, &analysis)
.context("Failed to write JSON output")?;
println!();
println!("Letterbox Analysis Complete!");
println!();
println!("Pairs found: {}", analysis.total_pairs);
println!("Space recoverable: {:.2} MB", analysis.total_space_recoverable as f64 / 1_048_576.0);
println!("Skipped (non-iPhone): {}", analysis.skipped_non_iphone);
println!("Skipped (ambiguous): {}", analysis.skipped_ambiguous);
println!();
println!("Output written to: {}", output.display());
Ok(())
}
#[derive(Debug, Serialize)]
struct LetterboxPairVerification {
timestamp: String,
keeper_status: AssetStatus,
delete_status: AssetStatus,
}
#[derive(Debug, Serialize)]
struct LetterboxVerificationReport {
verified_at: DateTime<Utc>,
server_url: String,
pairs_verified: usize,
keepers_present: usize,
keepers_missing: usize,
deletes_removed: usize,
deletes_still_present: usize,
pairs: Vec<LetterboxPairVerification>,
anomalies: Vec<String>,
}
async fn run_letterbox_verify(url: &str, api_key: &str, analysis_json: &PathBuf, format: &str) -> Result<()> {
println!("Verifying letterbox post-execution state...");
println!("Analysis file: {}", analysis_json.display());
println!();
let file = File::open(analysis_json)
.with_context(|| format!("Failed to open analysis file: {}", analysis_json.display()))?;
let reader = BufReader::new(file);
let analysis: LetterboxAnalysis = serde_json::from_reader(reader)
.context("Failed to parse letterbox analysis JSON")?;
let client = ImmichClient::new(url, api_key).context("Failed to create Immich client")?;
let mut pairs_verified = 0;
let mut keepers_present = 0;
let mut keepers_missing = 0;
let mut deletes_removed = 0;
let mut deletes_still_present = 0;
let mut pair_results = Vec::new();
let mut anomalies = Vec::new();
println!("Checking {} pairs...", analysis.pairs.len());
println!();
for pair in &analysis.pairs {
pairs_verified += 1;
let keeper_status = match client.get_asset(&pair.keeper.id).await {
Ok(_asset) => {
keepers_present += 1;
AssetStatus {
asset_id: pair.keeper.id.clone(),
filename: pair.keeper.original_file_name.clone(),
status: "present".to_string(),
error: None,
}
}
Err(immich_lib::ImmichError::Api { status: 404, .. }) => {
keepers_missing += 1;
anomalies.push(format!(
"CRITICAL: Keeper {} ({}) was deleted!",
pair.keeper.id, pair.keeper.original_file_name
));
AssetStatus {
asset_id: pair.keeper.id.clone(),
filename: pair.keeper.original_file_name.clone(),
status: "deleted".to_string(),
error: Some("Keeper was incorrectly deleted".to_string()),
}
}
Err(e) => {
keepers_missing += 1;
anomalies.push(format!(
"Error checking keeper {}: {}",
pair.keeper.id, e
));
AssetStatus {
asset_id: pair.keeper.id.clone(),
filename: pair.keeper.original_file_name.clone(),
status: "error".to_string(),
error: Some(e.to_string()),
}
}
};
let delete_status = match client.get_asset(&pair.delete.id).await {
Ok(asset) => {
if asset.is_trashed {
deletes_removed += 1;
AssetStatus {
asset_id: pair.delete.id.clone(),
filename: pair.delete.original_file_name.clone(),
status: "trashed".to_string(),
error: None,
}
} else {
deletes_still_present += 1;
anomalies.push(format!(
"Delete {} ({}) still exists (not trashed), should be deleted",
pair.delete.id, pair.delete.original_file_name
));
AssetStatus {
asset_id: pair.delete.id.clone(),
filename: pair.delete.original_file_name.clone(),
status: "present".to_string(),
error: Some("Delete should have been removed".to_string()),
}
}
}
Err(immich_lib::ImmichError::Api { status: 404, .. }) => {
deletes_removed += 1;
AssetStatus {
asset_id: pair.delete.id.clone(),
filename: pair.delete.original_file_name.clone(),
status: "deleted".to_string(),
error: None,
}
}
Err(e) => {
anomalies.push(format!(
"Error checking delete {}: {}",
pair.delete.id, e
));
AssetStatus {
asset_id: pair.delete.id.clone(),
filename: pair.delete.original_file_name.clone(),
status: "error".to_string(),
error: Some(e.to_string()),
}
}
};
pair_results.push(LetterboxPairVerification {
timestamp: pair.timestamp.clone(),
keeper_status,
delete_status,
});
if pairs_verified % 10 == 0 {
print!(".");
std::io::stdout().flush()?;
}
}
if !analysis.pairs.is_empty() {
println!();
println!();
}
let report = LetterboxVerificationReport {
verified_at: Utc::now(),
server_url: url.to_string(),
pairs_verified,
keepers_present,
keepers_missing,
deletes_removed,
deletes_still_present,
pairs: pair_results,
anomalies: anomalies.clone(),
};
match format.to_lowercase().as_str() {
"json" => {
println!("{}", serde_json::to_string_pretty(&report)?);
}
_ => {
println!("Letterbox Verification Report");
println!("=============================");
println!();
println!("Pairs verified: {}", pairs_verified);
println!("Keepers present: {}/{}", keepers_present, pairs_verified);
println!("Keepers missing: {}", keepers_missing);
println!("Deletes removed: {}", deletes_removed);
println!("Deletes still present: {}", deletes_still_present);
if !anomalies.is_empty() {
println!();
println!("Anomalies ({}):", anomalies.len());
for anomaly in &anomalies {
println!(" - {}", anomaly);
}
}
println!();
if keepers_missing == 0 && deletes_still_present == 0 {
println!("VERIFICATION PASSED");
} else {
println!("VERIFICATION FAILED");
}
}
}
Ok(())
}
#[derive(Debug, Serialize)]
struct LetterboxExecutionReport {
executed_at: DateTime<Utc>,
server_url: String,
total_pairs: usize,
downloaded: usize,
deleted: usize,
failed: usize,
skipped: usize,
results: Vec<LetterboxPairResult>,
}
#[derive(Debug, Serialize)]
struct LetterboxPairResult {
timestamp: String,
keeper_id: String,
delete_id: String,
download_status: String,
delete_status: String,
error: Option<String>,
}
async fn run_letterbox_execute(
url: &str,
api_key: &str,
input: &PathBuf,
backup_dir: &PathBuf,
force: bool,
rate_limit: u32,
yes: bool,
) -> Result<()> {
let file = File::open(input)
.with_context(|| format!("Failed to open input file: {}", input.display()))?;
let reader = BufReader::new(file);
let analysis: LetterboxAnalysis = serde_json::from_reader(reader)
.context("Failed to parse letterbox analysis JSON")?;
if analysis.pairs.is_empty() {
println!("No letterbox pairs to process.");
return Ok(());
}
std::fs::create_dir_all(backup_dir)
.with_context(|| format!("Failed to create backup directory: {}", backup_dir.display()))?;
println!();
println!("Letterbox Execution Plan");
println!("========================");
println!("Pairs to process: {}", analysis.pairs.len());
if analysis.total_space_recoverable > 0 {
let size_mb = analysis.total_space_recoverable as f64 / 1_048_576.0;
println!("Estimated disk space: {:.1} MB", size_mb);
}
println!("Backup directory: {}", backup_dir.display());
println!("Force delete: {}", if force { "yes (permanent)" } else { "no (trash)" });
println!();
if !yes {
print!(
"About to download {} files and delete them from Immich. Continue? [y/N] ",
analysis.pairs.len()
);
std::io::stdout().flush()?;
let mut response = String::new();
std::io::stdin().read_line(&mut response)?;
let response = response.trim().to_lowercase();
if response != "y" && response != "yes" {
println!("Aborted.");
return Ok(());
}
}
println!();
println!("Starting letterbox execution...");
println!();
let client = ImmichClient::new(url, api_key).context("Failed to create Immich client")?;
let quota = Quota::per_second(NonZeroU32::new(rate_limit).unwrap_or(NonZeroU32::new(10).unwrap()));
let rate_limiter = RateLimiter::direct(quota);
let mut results = Vec::new();
let mut downloaded_count = 0;
let mut deleted_count = 0;
let mut failed_count = 0;
let mut skipped_count = 0;
let total = analysis.pairs.len();
let pb = ProgressBar::new(total as u64);
pb.set_style(
ProgressStyle::default_bar()
.template("[{elapsed_precise}] {bar:40.cyan/blue} {pos}/{len} pairs ({eta})")
.expect("valid template")
.progress_chars("##-"),
);
for pair in analysis.pairs.iter() {
let delete_id = &pair.delete.id;
let delete_filename = &pair.delete.original_file_name;
pb.set_message(delete_filename.clone());
rate_limiter.until_ready().await;
let safe_filename = format!("{}_{}", &delete_id[..8.min(delete_id.len())], delete_filename);
let backup_path = backup_dir.join(&safe_filename);
let download_result = client.download_asset(delete_id, &backup_path).await;
match download_result {
Ok(_) => {
downloaded_count += 1;
rate_limiter.until_ready().await;
let delete_result = client.delete_assets(std::slice::from_ref(delete_id), force).await;
match delete_result {
Ok(_) => {
deleted_count += 1;
results.push(LetterboxPairResult {
timestamp: pair.timestamp.clone(),
keeper_id: pair.keeper.id.clone(),
delete_id: delete_id.clone(),
download_status: "success".to_string(),
delete_status: if force { "deleted" } else { "trashed" }.to_string(),
error: None,
});
}
Err(e) => {
failed_count += 1;
results.push(LetterboxPairResult {
timestamp: pair.timestamp.clone(),
keeper_id: pair.keeper.id.clone(),
delete_id: delete_id.clone(),
download_status: "success".to_string(),
delete_status: "failed".to_string(),
error: Some(e.to_string()),
});
}
}
}
Err(e) => {
failed_count += 1;
skipped_count += 1;
results.push(LetterboxPairResult {
timestamp: pair.timestamp.clone(),
keeper_id: pair.keeper.id.clone(),
delete_id: delete_id.clone(),
download_status: "failed".to_string(),
delete_status: "skipped".to_string(),
error: Some(e.to_string()),
});
}
}
pb.inc(1);
}
pb.finish_and_clear();
println!();
println!("Letterbox Execution Complete");
println!("============================");
println!("Pairs processed: {}", total);
println!("Files downloaded: {}", downloaded_count);
println!("Files deleted: {}", deleted_count);
println!("Failed: {}", failed_count);
println!("Skipped: {}", skipped_count);
let report = LetterboxExecutionReport {
executed_at: Utc::now(),
server_url: url.to_string(),
total_pairs: total,
downloaded: downloaded_count,
deleted: deleted_count,
failed: failed_count,
skipped: skipped_count,
results,
};
let timestamp = Utc::now().format("%Y%m%d-%H%M%S");
let report_path = backup_dir.join(format!("letterbox-execution-{}.json", timestamp));
let report_file = File::create(&report_path)
.with_context(|| format!("Failed to create report file: {}", report_path.display()))?;
let writer = BufWriter::new(report_file);
serde_json::to_writer_pretty(writer, &report)
.context("Failed to write execution report")?;
println!();
println!("Execution report: {}", report_path.display());
Ok(())
}