use std::sync::Arc; use psysonic_analysis::analysis_runtime::{enqueue_track_analysis, AnalysisBackfillPriority}; use psysonic_audio as audio; use psysonic_core::user_agent::subsonic_wire_user_agent; use tauri::Manager; use crate::file_transfer::{apply_server_http_get, stream_to_file}; use super::downloads::{resolve_hot_cache_root, HotCacheDownloadResult}; use super::offline::enqueue_analysis_seed_from_file; #[tauri::command] #[specta::specta] pub async fn download_track_hot_cache( track_id: String, server_id: String, url: String, suffix: String, custom_dir: Option, app: tauri::AppHandle, ) -> Result { let root = resolve_hot_cache_root(custom_dir, &app)?; let cache_dir = root.join(&server_id); tokio::fs::create_dir_all(&cache_dir) .await .map_err(|e| e.to_string())?; let file_path = cache_dir.join(format!("{}.{}", track_id, suffix)); let path_str = file_path.to_string_lossy().to_string(); if file_path.exists() { let size = tokio::fs::metadata(&file_path) .await .map(|m| m.len()) .unwrap_or(0); crate::app_deprintln!( "[hot-cache] download disk_hit track_id={} server_id={} bytes={}", track_id, server_id, size ); // Disk hit: still seed analysis, but do not block the command (full-file read); the // prefetch worker runs invokes sequentially. let app_seed = app.clone(); let tid = track_id.clone(); let sid = server_id.clone(); let fp = file_path.clone(); tokio::spawn(async move { enqueue_analysis_seed_from_file( &app_seed, &sid, &tid, &fp, Some(AnalysisBackfillPriority::Middle), ) .await; }); return Ok(HotCacheDownloadResult { path: path_str, size, }); } crate::app_deprintln!( "[hot-cache] download http_start track_id={} server_id={}", track_id, server_id ); let client = reqwest::Client::builder() .user_agent(subsonic_wire_user_agent()) .timeout(std::time::Duration::from_secs(120)) .build() .map_err(|e| e.to_string())?; let http_registry = app .try_state::>() .map(|s| Arc::clone(&*s)); let response = apply_server_http_get(&client, http_registry.as_deref(), Some(&server_id), &url) .send() .await .map_err(|e| e.to_string())?; if !response.status().is_success() { return Err(format!("HTTP {}", response.status().as_u16())); } // Stream directly to a .part file; rename on success to avoid partial files. let part_path = file_path.with_extension(format!("{suffix}.part")); if let Err(e) = stream_to_file(response, &part_path, None).await { let _ = tokio::fs::remove_file(&part_path).await; return Err(e); } tokio::fs::rename(&part_path, &file_path) .await .map_err(|e| e.to_string())?; let app_seed = app.clone(); let tid = track_id.clone(); let sid = server_id.clone(); let fp = file_path.clone(); tokio::spawn(async move { enqueue_analysis_seed_from_file( &app_seed, &sid, &tid, &fp, Some(AnalysisBackfillPriority::Middle), ) .await; }); let size = tokio::fs::metadata(&file_path) .await .map(|m| m.len()) .unwrap_or(0); crate::app_deprintln!( "[hot-cache] download http_done track_id={} server_id={} bytes={}", track_id, server_id, size ); Ok(HotCacheDownloadResult { path: path_str, size, }) } /// Promotes bytes captured by the manual streaming path into hot cache on disk. /// Returns `Ok(None)` when no completed stream cache is available for this URL. #[tauri::command] #[specta::specta] pub async fn promote_stream_cache_to_hot_cache( track_id: String, server_id: String, url: String, suffix: String, custom_dir: Option, app: tauri::AppHandle, state: tauri::State<'_, audio::AudioEngine>, ) -> Result, String> { let root = resolve_hot_cache_root(custom_dir, &app)?; let cache_dir = root.join(&server_id); tokio::fs::create_dir_all(&cache_dir) .await .map_err(|e| e.to_string())?; let file_path = cache_dir.join(format!("{}.{}", track_id, suffix)); let path_str = file_path.to_string_lossy().to_string(); if file_path.exists() { let size = tokio::fs::metadata(&file_path) .await .map(|m| m.len()) .unwrap_or(0); crate::app_deprintln!( "[hot-cache] promote disk_hit track_id={} server_id={} bytes={}", track_id, server_id, size ); let app_seed = app.clone(); let tid = track_id.clone(); let sid = server_id.clone(); let fp = file_path.clone(); tokio::spawn(async move { enqueue_analysis_seed_from_file(&app_seed, &sid, &tid, &fp, None).await; }); return Ok(Some(HotCacheDownloadResult { path: path_str, size })); } if let Some(bytes) = audio::take_stream_completed_for_url(&state, &url) { let part_path = file_path.with_extension(format!("{suffix}.part")); if let Err(e) = tokio::fs::write(&part_path, &bytes).await { let _ = tokio::fs::remove_file(&part_path).await; return Err(e.to_string()); } tokio::fs::rename(&part_path, &file_path) .await .map_err(|e| e.to_string())?; let priority = psysonic_analysis::analysis_runtime::analysis_backfill_resolve_priority( &app, &server_id, &track_id, None, ); let format_hint = Some(suffix.to_ascii_lowercase()); let _ = enqueue_track_analysis( &app, &server_id, &track_id, &bytes, format_hint.as_deref(), priority, ) .await; let size = tokio::fs::metadata(&file_path) .await .map(|m| m.len()) .unwrap_or(0); crate::app_deprintln!( "[hot-cache] promote from_stream_ram track_id={} server_id={} bytes={}", track_id, server_id, size ); return Ok(Some(HotCacheDownloadResult { path: path_str, size })); } if let Some(spill_path) = audio::take_stream_completed_spill_for_url(&state, &url) { if let Err(e) = tokio::fs::rename(&spill_path, &file_path).await { if let Err(copy_err) = tokio::fs::copy(&spill_path, &file_path).await { let _ = tokio::fs::remove_file(&spill_path).await; return Err(format!("promote spill rename: {e}; copy: {copy_err}")); } let _ = tokio::fs::remove_file(&spill_path).await; } let app_seed = app.clone(); let tid = track_id.clone(); let sid = server_id.clone(); let fp = file_path.clone(); tokio::spawn(async move { enqueue_analysis_seed_from_file(&app_seed, &sid, &tid, &fp, None).await; }); let size = tokio::fs::metadata(&file_path) .await .map(|m| m.len()) .unwrap_or(0); crate::app_deprintln!( "[hot-cache] promote from_stream_spill track_id={} server_id={} bytes={}", track_id, server_id, size ); return Ok(Some(HotCacheDownloadResult { path: path_str, size })); } crate::app_deprintln!( "[hot-cache] promote skip track_id={} reason=no_completed_stream_for_url", track_id ); Ok(None) } #[tauri::command] #[specta::specta] pub async fn get_hot_cache_size(custom_dir: Option, app: tauri::AppHandle) -> u64 { resolve_hot_cache_root(custom_dir, &app) .map(|root| super::fs_utils::dir_size_recursive(&root)) .unwrap_or(0) } #[tauri::command] #[specta::specta] pub async fn delete_hot_cache_track( local_path: String, custom_dir: Option, app: tauri::AppHandle, ) -> Result<(), String> { let file_path = std::path::PathBuf::from(&local_path); let existed = file_path.exists(); if existed { tokio::fs::remove_file(&file_path) .await .map_err(|e| e.to_string())?; } crate::app_deprintln!( "[hot-cache] delete file existed={} path_suffix={}", existed, file_path .file_name() .and_then(|s| s.to_str()) .unwrap_or("?") ); let boundary = resolve_hot_cache_root(custom_dir, &app)?; if let Some(parent) = file_path.parent() { super::fs_utils::prune_empty_dirs_up_to(parent, &boundary); } Ok(()) } /// Removes the entire hot cache root (`psysonic-hot-cache` for the active location). #[tauri::command] #[specta::specta] pub async fn purge_hot_cache(custom_dir: Option, app: tauri::AppHandle) -> Result<(), String> { let root = resolve_hot_cache_root(custom_dir, &app)?; if !root.exists() { return Ok(()); } tokio::fs::remove_dir_all(&root) .await .map_err(|e| e.to_string())?; crate::app_deprintln!( "[hot-cache] purge root={} status=ok", root.to_string_lossy() ); Ok(()) }