mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
6ea0acede5
* feat(playback): stream buffering UI, ranged M4A tail prefetch, demuxer fix Defer seekbar/progress until HTTP stream is armed for both legacy and RangedHttpSource; show buffering overlay on cover art. Add MP4 tail prefetch and Symphonia isomp4 bounded-mdat/moov-at-EOF probing so moov-at-end M4A can start without reading the full mdat. * feat(hot-cache): spill large ranged streams to disk for promote When a ranged HTTP download completes above the 64 MiB RAM promote cap, write the existing buffer once to app-data stream-spill/ and register it for hot-cache promote (rename) and replay via fetch_data. Analysis seeds from the spill file up to the local-file cap (512 MiB). * fix(ui): stream buffering — grayscale cover and static clock icon Desaturate player and queue cover art while isPlaybackBuffering; keep a non-animated clock overlay for visibility without the spinning animation. * fix(playback): review follow-up — tests, i18n, spill cleanup, changelog Clippy and test layout fixes; stream spill orphan cleanup on startup; buffering flag guard in progress handler; bufferingStream in all player locales; CHANGELOG and contributor credits for stream/M4A work. * docs: attribute stream buffering and M4A streaming to PR #737 * test(audio): avoid create_engine in stream spill unit test CI runners have no audio output device; test spill take/consume via the Mutex slot only, matching install_stream_completed_spill tests.
256 lines
8.2 KiB
Rust
256 lines
8.2 KiB
Rust
use psysonic_analysis::analysis_runtime::enqueue_analysis_seed;
|
|
use psysonic_audio as audio;
|
|
use psysonic_core::user_agent::subsonic_wire_user_agent;
|
|
|
|
use crate::file_transfer::stream_to_file;
|
|
use super::downloads::{resolve_hot_cache_root, HotCacheDownloadResult};
|
|
use super::offline::enqueue_analysis_seed_from_file;
|
|
|
|
#[tauri::command]
|
|
pub async fn download_track_hot_cache(
|
|
track_id: String,
|
|
server_id: String,
|
|
url: String,
|
|
suffix: String,
|
|
custom_dir: Option<String>,
|
|
app: tauri::AppHandle,
|
|
) -> Result<HotCacheDownloadResult, 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] 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 fp = file_path.clone();
|
|
tokio::spawn(async move {
|
|
enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).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 response = client.get(&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 fp = file_path.clone();
|
|
tokio::spawn(async move {
|
|
enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).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]
|
|
pub async fn promote_stream_cache_to_hot_cache(
|
|
track_id: String,
|
|
server_id: String,
|
|
url: String,
|
|
suffix: String,
|
|
custom_dir: Option<String>,
|
|
app: tauri::AppHandle,
|
|
state: tauri::State<'_, audio::AudioEngine>,
|
|
) -> Result<Option<HotCacheDownloadResult>, 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 fp = file_path.clone();
|
|
tokio::spawn(async move {
|
|
enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).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 _ = enqueue_analysis_seed(&app, &track_id, &bytes).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 fp = file_path.clone();
|
|
tokio::spawn(async move {
|
|
enqueue_analysis_seed_from_file(&app_seed, &tid, &fp).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]
|
|
pub async fn get_hot_cache_size(custom_dir: Option<String>, 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]
|
|
pub async fn delete_hot_cache_track(
|
|
local_path: String,
|
|
custom_dir: Option<String>,
|
|
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]
|
|
pub async fn purge_hot_cache(custom_dir: Option<String>, 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(())
|
|
}
|