kopuz-pages 0.8.2

A modern, lightweight music player built with Rust and Dioxus.
pub mod discover;
pub mod download_manager;
pub mod subsonic_sync;

use config::{AppConfig, MusicService};
use dioxus::prelude::{ReadableExt, WritableExt};
use std::path::PathBuf;

pub(super) fn offline_cache_dir() -> PathBuf {
    #[cfg(not(target_arch = "wasm32"))]
    {
        let base = directories::ProjectDirs::from("com", "temidaradev", "kopuz")
            .map(|dirs| dirs.cache_dir().to_path_buf())
            .unwrap_or_else(|| PathBuf::from("./cache"));
        let dir = base.join("offline_tracks");
        let _ = std::fs::create_dir_all(&dir);
        dir
    }
    #[cfg(target_arch = "wasm32")]
    PathBuf::from("./cache/offline_tracks")
}

pub fn build_download_url(item_id: &str, config: &AppConfig) -> Option<(String, &'static str)> {
    let server = config.server.as_ref()?;
    let quality = config.offline_quality;
    let ext = quality.file_extension();

    let url = match server.service {
        MusicService::Jellyfin => {
            let token = server.access_token.as_deref().unwrap_or("");
            match quality.jellyfin_bitrate_bps() {
                Some(bps) => format!(
                    "{}/Audio/{}/stream?audioBitRate={}&audioCodec=mp3&api_key={}",
                    server.url, item_id, bps, token
                ),
                None => format!(
                    "{}/Audio/{}/stream?static=true&api_key={}",
                    server.url, item_id, token
                ),
            }
        }
        MusicService::Subsonic | MusicService::Custom => {
            let username = server.user_id.as_deref()?;
            let password_or_token = server.access_token.as_deref()?;
            let resolved_password = ::server::provider::resolve_subsonic_secret(password_or_token)?;
            let kbps = quality.subsonic_max_bitrate_kbps();
            ::server::subsonic::stream_url_with_bitrate(
                &server.url,
                username,
                &resolved_password,
                item_id,
                Some(kbps),
            )
            .ok()?
        }
        MusicService::YtMusic | MusicService::SoundCloud => return None,
    };
    Some((url, ext))
}

#[cfg(not(target_arch = "wasm32"))]
pub(super) fn content_type_to_ext(content_type: &str) -> Option<&'static str> {
    let ct = content_type.split(';').next().unwrap_or("").trim();
    match ct {
        "audio/flac" | "audio/x-flac" => Some("flac"),
        "audio/mpeg" | "audio/mp3" => Some("mp3"),
        "audio/mp4" | "audio/x-m4a" | "video/mp4" => Some("m4a"),
        "audio/ogg" | "audio/opus" => Some("ogg"),
        "audio/webm" | "video/webm" => Some("webm"),
        "audio/x-matroska" | "audio/matroska" | "video/x-matroska" | "video/matroska" => {
            Some("mka")
        }
        "audio/aac" => Some("aac"),
        "audio/wav" | "audio/x-wav" => Some("wav"),
        "audio/aiff" | "audio/x-aiff" => Some("aiff"),
        _ => None,
    }
}

#[cfg(not(target_arch = "wasm32"))]
#[tracing::instrument(name = "download.to_cache", skip(url), fields(item_id = %item_id))]
pub async fn download_track_to_cache(
    item_id: &str,
    url: &str,
    ext_hint: &str,
) -> Result<PathBuf, String> {
    let response = reqwest::get(url)
        .await
        .map_err(|e| format!("Download failed: {e}"))?;

    let ext = response
        .headers()
        .get(reqwest::header::CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .and_then(content_type_to_ext)
        .unwrap_or(ext_hint);

    let bytes = response
        .bytes()
        .await
        .map_err(|e| format!("Failed to read response: {e}"))?;

    let dir = offline_cache_dir();
    let file_path = dir.join(format!("{item_id}.{ext}"));
    tokio::fs::write(&file_path, &bytes)
        .await
        .map_err(|e| format!("Failed to save file: {e}"))?;

    Ok(file_path)
}

#[cfg(not(target_arch = "wasm32"))]
pub async fn download_tracks_batch(
    item_ids: Vec<String>,
    mut config: dioxus::prelude::Signal<AppConfig>,
) {
    for id in item_ids {
        let is_downloaded = if let Some(path_str) = config.read().offline_tracks.get(&id) {
            std::path::Path::new(path_str).exists()
        } else {
            false
        };
        if is_downloaded {
            continue;
        }
        let result = {
            let conf = config.read();
            build_download_url(&id, &conf)
        };
        if let Some((url, ext)) = result {
            match download_track_to_cache(&id, &url, ext).await {
                Ok(path) => {
                    config
                        .write()
                        .offline_tracks
                        .insert(id.clone(), path.to_string_lossy().into_owned());
                }
                Err(e) => tracing::warn!(%id, error = %e, "batch download failed"),
            }
        }
    }
}

#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
    use super::content_type_to_ext;

    #[test]
    fn maps_matroska_audio_to_mka() {
        assert_eq!(content_type_to_ext("audio/x-matroska"), Some("mka"));
        assert_eq!(content_type_to_ext("audio/matroska"), Some("mka"));
        assert_eq!(content_type_to_ext("video/x-matroska"), Some("mka"));
        assert_eq!(content_type_to_ext("video/matroska"), Some("mka"));
    }

    #[test]
    fn maps_matroska_with_codec_parameters() {
        assert_eq!(
            content_type_to_ext("audio/x-matroska; codecs=opus"),
            Some("mka")
        );
    }

    #[test]
    fn unknown_container_returns_none() {
        assert_eq!(content_type_to_ext("application/octet-stream"), None);
    }

    #[test]
    fn preserves_existing_mappings() {
        assert_eq!(content_type_to_ext("audio/mpeg"), Some("mp3"));
        assert_eq!(content_type_to_ext("audio/flac"), Some("flac"));
        assert_eq!(content_type_to_ext("audio/mp4"), Some("m4a"));
        assert_eq!(content_type_to_ext("audio/ogg"), Some("ogg"));
    }
}