romm-api 1.1.0

HTTP client and domain logic for the RomM API
Documentation
use std::path::PathBuf;
use std::sync::{Arc, Mutex};

use crate::client::RommClient;
use crate::config::RomsLayoutConfig;
use crate::core::extras::{build_base_rom_file_targets, DownloadTarget};
use crate::core::interrupt::is_cancelled_download;
use crate::core::utils;
use crate::error::DownloadError;
use crate::types::Rom;

use super::super::job::{DownloadJob, DownloadStatus};
use super::super::paths::{resolve_console_roms_dir, resolve_download_directory};
use super::super::transfer::{
    download_target_with_fallback, finalize_download, prepare_download_target_destination,
    sanitized_final_filename, FinalizeResult,
};
use super::DownloadManager;

struct RomDownloadTask {
    client: RommClient,
    jobs: Arc<Mutex<Vec<DownloadJob>>>,
    job_id: usize,
    rom_id: u64,
    fs_name: String,
    final_name: String,
    save_dir: PathBuf,
    console_dir: PathBuf,
    base_targets: Vec<DownloadTarget>,
}

impl DownloadManager {
    pub fn start_download(
        &self,
        rom: &Rom,
        client: RommClient,
        layout: &RomsLayoutConfig,
        configured_download_dir: Option<&str>,
    ) -> Result<(), DownloadError> {
        let platform = rom
            .platform_display_name
            .as_deref()
            .or(rom.platform_custom_name.as_deref())
            .unwrap_or("")
            .to_string();

        let job = DownloadJob::new(rom.id, rom.name.clone(), platform);
        let job_id = job.id;
        let rom_id = rom.id;
        let fs_name = rom.fs_name.clone();
        let final_name = sanitized_final_filename(&rom.fs_name, rom.id);
        let rom_for_targets = rom.clone();
        let layout = layout.clone();
        match self.jobs.lock() {
            Ok(mut jobs) => jobs.push(job),
            Err(err) => {
                eprintln!("warning: download job list lock poisoned: {}", err);
                return Err(DownloadError::JobListPoisoned(err.to_string()));
            }
        }

        let save_dir = resolve_download_directory(configured_download_dir)?;
        let console_dir = resolve_console_roms_dir(&layout, &save_dir, rom)?;
        let base_targets = build_base_rom_file_targets(&rom_for_targets, &layout, &save_dir)?;
        let task = RomDownloadTask {
            client,
            jobs: self.jobs.clone(),
            job_id,
            rom_id,
            fs_name,
            final_name,
            save_dir,
            console_dir,
            base_targets,
        };
        tokio::spawn(run_rom_download_task(task));
        Ok(())
    }
}

async fn run_rom_download_task(task: RomDownloadTask) {
    let temp_root = task.save_dir.join(".tmp");
    if let Err(err) = tokio::fs::create_dir_all(&temp_root).await {
        set_job_status(
            &task.jobs,
            task.job_id,
            DownloadStatus::Error(format!(
                "Could not create temp directory {}: {err}",
                temp_root.display()
            )),
        );
        return;
    }

    let final_path = task.console_dir.join(task.final_name.clone());
    if let Err(err) = tokio::fs::create_dir_all(&task.console_dir).await {
        set_job_status(
            &task.jobs,
            task.job_id,
            DownloadStatus::Error(format!(
                "Could not create console directory {}: {err}",
                task.console_dir.display()
            )),
        );
        return;
    }

    if !task.base_targets.is_empty() {
        download_base_targets(&task.client, &task.jobs, task.job_id, &task.base_targets).await;
        return;
    }

    download_primary_rom(
        &task.client,
        &task.jobs,
        task.job_id,
        task.rom_id,
        &task.fs_name,
        &temp_root,
        &final_path,
    )
    .await;
}

async fn download_base_targets(
    client: &RommClient,
    jobs: &Arc<Mutex<Vec<DownloadJob>>>,
    job_id: usize,
    base_targets: &[DownloadTarget],
) {
    let total_targets = base_targets.len() as f64;
    for (idx, target) in base_targets.iter().enumerate() {
        let progress_jobs = jobs.clone();
        let mut progress = move |received: u64, total: u64| {
            let file_ratio = if total > 0 {
                received as f64 / total as f64
            } else {
                0.0
            };
            let total_ratio = ((idx as f64) + file_ratio) / total_targets;
            set_job_progress(&progress_jobs, job_id, total_ratio.min(1.0));
        };

        match prepare_download_target_destination(target).await {
            Ok(true) => {
                progress(
                    target.expected_size_bytes.unwrap_or(0),
                    target.expected_size_bytes.unwrap_or(0),
                );
                continue;
            }
            Ok(false) => {}
            Err(err) => {
                set_job_status(jobs, job_id, DownloadStatus::Error(err.to_string()));
                return;
            }
        }

        if let Err(final_err) =
            download_target_with_fallback(client, target, |_, _| false, &mut progress).await
        {
            set_job_status(jobs, job_id, DownloadStatus::Error(final_err.to_string()));
            return;
        }
    }
    finish_job(jobs, job_id, DownloadStatus::Done);
}

async fn download_primary_rom(
    client: &RommClient,
    jobs: &Arc<Mutex<Vec<DownloadJob>>>,
    job_id: usize,
    rom_id: u64,
    fs_name: &str,
    temp_root: &std::path::Path,
    final_path: &std::path::Path,
) {
    if final_path.exists() {
        finish_job(jobs, job_id, DownloadStatus::SkippedAlreadyExists);
        return;
    }

    let temp_name = format!(
        "rom-{}-{}-{}.part",
        rom_id,
        utils::sanitize_filename(fs_name),
        job_id
    );
    let temp_path = temp_root.join(temp_name);
    let progress_jobs = jobs.clone();
    let on_progress = move |received: u64, total: u64| {
        let p = if total > 0 {
            received as f64 / total as f64
        } else {
            0.0
        };

        set_job_progress(&progress_jobs, job_id, p);
    };

    let download_result = client.download_rom(rom_id, &temp_path, on_progress).await;
    if download_result.is_err() {
        let _ = tokio::fs::remove_file(&temp_path).await;
    }
    match download_result {
        Ok(()) => match finalize_download(&temp_path, final_path).await {
            Ok(FinalizeResult::Done) => {
                finish_job(jobs, job_id, DownloadStatus::Done);
            }
            Ok(FinalizeResult::SkippedAlreadyExists) => {
                finish_job(jobs, job_id, DownloadStatus::SkippedAlreadyExists);
            }
            Err(err) => {
                let _ = tokio::fs::remove_file(&temp_path).await;
                set_job_status(
                    jobs,
                    job_id,
                    DownloadStatus::FinalizeFailed(err.to_string()),
                );
            }
        },
        Err(e) => {
            if is_cancelled_download(&e) {
                set_job_status(jobs, job_id, DownloadStatus::Cancelled);
            } else {
                set_job_status(jobs, job_id, DownloadStatus::Error(e.to_string()));
            }
        }
    }
}

fn update_download_job<F>(jobs: &Arc<Mutex<Vec<DownloadJob>>>, job_id: usize, update: F)
where
    F: FnOnce(&mut DownloadJob),
{
    if let Ok(mut list) = jobs.lock() {
        if let Some(job) = list.iter_mut().find(|job| job.id == job_id) {
            update(job);
        }
    }
}

fn set_job_progress(jobs: &Arc<Mutex<Vec<DownloadJob>>>, job_id: usize, progress: f64) {
    update_download_job(jobs, job_id, |job| {
        job.progress = progress;
    });
}

fn set_job_status(jobs: &Arc<Mutex<Vec<DownloadJob>>>, job_id: usize, status: DownloadStatus) {
    update_download_job(jobs, job_id, |job| {
        job.status = status;
    });
}

fn finish_job(jobs: &Arc<Mutex<Vec<DownloadJob>>>, job_id: usize, status: DownloadStatus) {
    update_download_job(jobs, job_id, |job| {
        job.status = status;
        job.progress = 1.0;
    });
}