romm-api 0.40.0

HTTP client and domain logic for the RomM API
Documentation
//! HTTP download, URL fallback, and finalize helpers.

use std::path::Path;

use crate::client::RommClient;
use crate::core::extras::DownloadTarget;
use crate::core::utils;
use crate::error::DownloadError;

pub async fn prepare_download_target_destination(
    target: &DownloadTarget,
) -> Result<bool, DownloadError> {
    let Some(expected_size) = target.expected_size_bytes else {
        return Ok(false);
    };
    if expected_size == 0 {
        return Ok(false);
    }

    let Ok(metadata) = tokio::fs::metadata(&target.destination).await else {
        return Ok(false);
    };
    let current_size = metadata.len();
    if current_size == expected_size {
        return Ok(true);
    }
    if current_size > expected_size {
        tokio::fs::remove_file(&target.destination)
            .await
            .map_err(|e| DownloadError::IoContext {
                context: format!(
                    "remove oversized stale download {} ({} > {} bytes)",
                    target.destination.display(),
                    current_size,
                    expected_size
                ),
                source: e,
            })?;
    }
    Ok(false)
}

pub(crate) async fn download_target_with_fallback<F, C>(
    client: &RommClient,
    target: &DownloadTarget,
    mut is_cancelled: C,
    on_progress: &mut F,
) -> Result<(), DownloadError>
where
    F: FnMut(u64, u64) + Send,
    C: FnMut(u64, u64) -> bool + Send,
{
    let urls = candidate_download_urls(target);
    let mut last_err: Option<DownloadError> = None;
    for url in urls {
        match client
            .download_url_with_query_with_cancel(
                &url,
                &target.source_query,
                &target.destination,
                &mut is_cancelled,
                on_progress,
            )
            .await
        {
            Ok(()) => return Ok(()),
            Err(err) => {
                if !err.is_not_found() {
                    return Err(err);
                }
                last_err = Some(err);
            }
        }
    }
    Err(last_err.unwrap_or(DownloadError::FailedWithoutDetails))
}

pub(crate) fn candidate_download_urls(target: &DownloadTarget) -> Vec<String> {
    let mut out = vec![target.source_url.clone()];
    if let Some((file_id, file_name)) = parse_current_rom_file_content_path(&target.source_url) {
        out.push(format!("/api/romsfiles/{file_id}/content/{file_name}"));
        out.push(format!("/api/roms/files/{file_id}/content/{file_name}"));
    } else if let Some((file_id, file_name)) = parse_romsfiles_path(&target.source_url) {
        out.push(format!("/api/roms/{file_id}/files/content/{file_name}"));
        out.push(format!("/api/roms/files/{file_id}/content/{file_name}"));
    } else if let Some((file_id, file_name)) = parse_legacy_roms_files_path(&target.source_url) {
        out.push(format!("/api/roms/{file_id}/files/content/{file_name}"));
        out.push(format!("/api/romsfiles/{file_id}/content/{file_name}"));
    }
    dedupe_preserve_order(out)
}

fn parse_current_rom_file_content_path(url: &str) -> Option<(String, String)> {
    let prefix = "/api/roms/";
    let marker = "/files/content/";
    let rest = url.strip_prefix(prefix)?;
    let (id, name) = rest.split_once(marker)?;
    Some((id.to_string(), name.to_string()))
}

fn parse_romsfiles_path(url: &str) -> Option<(String, String)> {
    let prefix = "/api/romsfiles/";
    let marker = "/content/";
    let rest = url.strip_prefix(prefix)?;
    let (id, name) = rest.split_once(marker)?;
    Some((id.to_string(), name.to_string()))
}

fn parse_legacy_roms_files_path(url: &str) -> Option<(String, String)> {
    let prefix = "/api/roms/files/";
    let marker = "/content/";
    let rest = url.strip_prefix(prefix)?;
    let (id, name) = rest.split_once(marker)?;
    Some((id.to_string(), name.to_string()))
}

fn dedupe_preserve_order(urls: Vec<String>) -> Vec<String> {
    let mut seen = std::collections::HashSet::new();
    let mut out = Vec::new();
    for u in urls {
        if seen.insert(u.clone()) {
            out.push(u);
        }
    }
    out
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FinalizeResult {
    Done,
    SkippedAlreadyExists,
}

pub(crate) async fn finalize_download(
    temp_path: &Path,
    final_path: &Path,
) -> Result<FinalizeResult, DownloadError> {
    if final_path.exists() {
        let _ = tokio::fs::remove_file(temp_path).await;
        return Ok(FinalizeResult::SkippedAlreadyExists);
    }

    match tokio::fs::rename(temp_path, final_path).await {
        Ok(()) => Ok(FinalizeResult::Done),
        Err(rename_err) if is_cross_device_rename_error(&rename_err) => {
            tokio::fs::copy(temp_path, final_path)
                .await
                .map_err(|e| DownloadError::IoContext {
                    context: format!(
                        "Could not copy temp ROM {} to final destination {}",
                        temp_path.display(),
                        final_path.display()
                    ),
                    source: e,
                })?;
            let file =
                tokio::fs::File::open(final_path)
                    .await
                    .map_err(|e| DownloadError::IoContext {
                        context: format!(
                            "Could not open finalized ROM for sync: {}",
                            final_path.display()
                        ),
                        source: e,
                    })?;
            file.sync_all()
                .await
                .map_err(|e| DownloadError::IoContext {
                    context: format!(
                        "Could not sync finalized ROM to disk: {}",
                        final_path.display()
                    ),
                    source: e,
                })?;
            tokio::fs::remove_file(temp_path)
                .await
                .map_err(|e| DownloadError::IoContext {
                    context: format!(
                        "Could not remove temp ROM after copy: {}",
                        temp_path.display()
                    ),
                    source: e,
                })?;
            Ok(FinalizeResult::Done)
        }
        Err(rename_err) => Err(DownloadError::RenameFailed {
            path: temp_path.display().to_string(),
            final_path: final_path.display().to_string(),
            source: rename_err,
        }),
    }
}

fn is_cross_device_rename_error(err: &std::io::Error) -> bool {
    matches!(err.raw_os_error(), Some(18) | Some(17))
}

pub(crate) fn sanitized_final_filename(fs_name: &str, rom_id: u64) -> String {
    let sanitized = utils::sanitize_filename(fs_name);
    if sanitized.trim().is_empty() {
        format!("rom-{rom_id}.zip")
    } else {
        sanitized
    }
}

#[cfg(test)]
pub(crate) fn final_download_path_for_rom(
    roms_dir: &Path,
    rom: &crate::types::Rom,
) -> std::path::PathBuf {
    let platform_slug = rom
        .platform_fs_slug
        .clone()
        .or_else(|| rom.platform_slug.clone())
        .unwrap_or_else(|| format!("platform-{}", rom.platform_id));
    let console_dir = roms_dir.join(utils::sanitize_filename(&platform_slug));
    console_dir.join(sanitized_final_filename(&rom.fs_name, rom.id))
}