playlists + player: every service's list through one door with its names matched for real, and shuffle that readies exactly what it promises

This commit is contained in:
KeelrLeek committed 2026-08-24 18:10:34 -05:00
1 parent 30eb6fe025
commit 50414dae5c
16 files changed
+4157 -154

No files matched your search

+201 -12
View File
@@ -1557,6 +1557,32 @@ pub async fn catalog_search(
Ok(state.metadata.catalog_search(&query).await)
}
#[tauri::command]
pub async fn catalog_search_tracks(
state: State<'_, LibraryState>,
query: String,
limit: Option<u32>,
) -> Result<Vec<metadata::CatalogTrack>, String> {
state.touch_catalog_activity();
Ok(state
.metadata
.catalog_search_tracks(&query, limit.unwrap_or(10))
.await)
}
#[tauri::command]
pub async fn catalog_artist_track_candidates(
state: State<'_, LibraryState>,
artist: String,
title: String,
) -> Result<Vec<metadata::CatalogTrack>, String> {
state.touch_catalog_activity();
Ok(state
.metadata
.catalog_artist_track_candidates(&artist, &title)
.await)
}
#[tauri::command]
pub async fn catalog_track_by_isrc(
state: State<'_, LibraryState>,
@@ -3592,7 +3618,16 @@ pub async fn save_to_library(
let mut organized = 0u32;
let mut queued = 0u32;
for f in files {
let cached = crate::playback::cache_path(&app, &f.username, &f.path).filter(|c| c.exists());
// Only a BYTE-COMPLETE cache saves directly: a mid-stream (or
// abandoned) cache file is a prefix of the track, and the organizer
// would file the truncation as the real thing. Anything shorter
// falls through — to the in-flight transfer if one is running, else
// to a fresh download of the known file.
let cached = crate::playback::cache_path(&app, &f.username, &f.path)
.filter(|c| c.exists())
.filter(|c| {
f.size == 0 || c.metadata().map(|m| m.len() == f.size).unwrap_or(false)
});
if let Some(cache) = cached {
if organize_cached_save(&app, &downloads_dir, &cache, &f.path).await? {
organized += 1;
@@ -3639,6 +3674,17 @@ pub async fn save_to_library(
Ok((organized, queued))
}
/// Byte length of the stream-cache copy of one peer file, if it exists.
/// The caller compares against the peer-stated size to tell a finished
/// cache from a mid-stream prefix. Read-only — never contacts the peer.
#[tauri::command]
pub fn stream_cache_len(app: tauri::AppHandle, username: String, path: String) -> Option<u64> {
crate::playback::cache_path(&app, &username, &path)
.filter(|c| c.exists())
.and_then(|c| c.metadata().ok())
.map(|m| m.len())
}
/// Copy a completed stream cache into staging, then run the normal tagging and
/// library organizer. Returns false only when the copy itself did not happen.
async fn organize_cached_save(
@@ -4172,19 +4218,100 @@ pub async fn refresh_all_album_art(app: tauri::AppHandle) -> Result<usize, Strin
/// A source already within bounds is passed through rather than re-encoded, so
/// this never costs quality it was meant to protect.
#[tauri::command]
pub async fn album_art(path: String, max_edge: Option<u32>) -> Result<String, String> {
let bytes = tauri::async_runtime::spawn_blocking(move || -> Result<Vec<u8>, String> {
let raw = std::fs::read(&path).map_err(|e| e.to_string())?;
let Some(edge) = max_edge else { return Ok(raw) };
// An undecodable or already-small source falls through to the original
// bytes: a thumbnail is an optimisation, never a reason to show nothing.
match pipeline::thumbnail_jpeg(&raw, edge) {
Ok(Some(thumb)) => Ok(thumb),
_ => Ok(raw),
}
})
pub async fn album_art(
app: tauri::AppHandle,
path: String,
max_edge: Option<u32>,
) -> Result<String, String> {
// Thumbnails used to cost a FULL master decode + Lanczos resize per
// request, on the unbounded blocking pool, with nothing remembered —
// so a Library page scrolling past a few hundred tiles pinned every
// core in image decodes and the AUDIO thread started to starve (the
// player decodes on its own std::thread, which only chops when the
// whole machine is saturated). Two changes, and either alone would
// have hidden the other's need:
// 1. a decode SEMAPHORE — at most 2 covers decode at once, ever;
// 2. a thumbnail disk cache keyed by (path, mtime, len, edge) — each
// cover is decoded once per size PER LIFETIME, then it's a small
// file read. Covers change by being replaced (cover_pick writes a
// new file), so the mtime+len key self-invalidates; orphaned
// entries are tens of KB and left to lie.
// Master requests (no edge: the player-bar hero) stay a plain read —
// no decode, no permit, no cache.
let thumb_dir = app
.path()
.app_cache_dir()
.ok()
.map(|d| d.join("art-thumbs-v1"));
// Phase 1 — cheap and permit-free: stat, cache probe, raw read.
let probe_path = path.clone();
let probe_dir = thumb_dir.clone();
let (bytes, write_to) = tauri::async_runtime::spawn_blocking(
move || -> Result<(Vec<u8>, Option<std::path::PathBuf>), String> {
let Some(edge) = max_edge else {
return Ok((std::fs::read(&probe_path).map_err(|e| e.to_string())?, None));
};
let target = probe_dir.and_then(|dir| {
let meta = std::fs::metadata(&probe_path).ok()?;
let mtime = meta
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_nanos();
let mut hash: u64 = 0xcbf29ce484222325;
for b in probe_path
.as_bytes()
.iter()
.chain(mtime.to_le_bytes().iter())
.chain(meta.len().to_le_bytes().iter())
{
hash ^= u64::from(*b);
hash = hash.wrapping_mul(0x100000001b3);
}
Some(dir.join(format!("{hash:016x}-{edge}.jpg")))
});
if let Some(t) = &target {
if let Ok(cached) = std::fs::read(t) {
return Ok((cached, None));
}
}
Ok((std::fs::read(&probe_path).map_err(|e| e.to_string())?, target))
},
)
.await
.map_err(|e| e.to_string())??;
// Phase 2 — the decode, gated. Runs only on a cache miss.
let bytes = match write_to {
None => bytes,
Some(target) => {
let _permit = ART_DECODE_SLOTS
.acquire()
.await
.map_err(|e| e.to_string())?;
let edge = max_edge.unwrap_or(0);
tauri::async_runtime::spawn_blocking(move || {
// An undecodable or already-small source falls through to the
// original bytes: a thumbnail is an optimisation, never a
// reason to show nothing. Either result is CACHED, so the
// "already small" discovery (itself a full decode) is also
// paid once.
let out = match pipeline::thumbnail_jpeg(&bytes, edge) {
Ok(Some(thumb)) => thumb,
_ => bytes,
};
if let Some(dir) = target.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(&target, &out); // failure-soft
out
})
.await
.map_err(|e| e.to_string())?
}
};
use base64::Engine as _;
Ok(format!(
"data:image/jpeg;base64,{}",
@@ -4192,6 +4319,68 @@ pub async fn album_art(path: String, max_edge: Option<u32>) -> Result<String, St
))
}
/// At most this many album-art decodes run at once, machine-wide. Art is
/// cosmetic; audio and the UI are not — two permits keep a burst of tile
/// requests from ever owning more than two cores.
static ART_DECODE_SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(2);
/// The cover a streamed track carries INSIDE its own bytes, extracted to a
/// file the ordinary art path can read.
///
/// A Soulseek transfer is a tagged audio file, and in the reference library
/// 449 of 515 records keep their best cover embedded — so a stream that
/// arrived without any catalog artwork usually still has the real thing a
/// tag read away, and that cover needs no name matching to be certain it
/// belongs to this recording. Extracted once, keyed by the bytes' own hash
/// so two tracks off one record share the file.
#[tauri::command]
pub async fn stream_embedded_art(
app: tauri::AppHandle,
username: String,
path: String,
) -> Result<Option<String>, String> {
let Some(cache) = playback::cache_path(&app, &username, &path) else {
return Ok(None);
};
// Beside the library, not in AppData. The path is persisted onto the
// playlist row, and `playlist_items.art_path` is one of the columns the
// portable-library rebase rewrites — a cover kept in AppData would opt
// out of that and dangle the first time the drive is opened elsewhere.
let dir = config::ensure_library_data_dir(&app, &config::load(&app))
.map_err(|e| e.to_string())?
.join("stream-art");
tauri::async_runtime::spawn_blocking(move || -> Result<Option<String>, String> {
// A transfer still in flight has no cache file yet (the bytes land in
// `<name>.partial`), and a tag read over a half-written one simply
// fails — either way the answer is "nothing yet", and the caller asks
// again when the transfer completes.
if !cache.exists() {
return Ok(None);
}
let Ok(Some(bytes)) = tagger::extract_art(&cache) else {
return Ok(None);
};
if bytes.is_empty() {
return Ok(None);
}
let mut hash: u64 = 0xcbf29ce484222325;
for b in &bytes {
hash ^= *b as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
// No extension the loader trusts: JPEG and PNG both appear in tags and
// the art pipeline sniffs the bytes anyway.
let out = dir.join(format!("{hash:016x}.img"));
if !matches!(std::fs::metadata(&out), Ok(md) if md.len() == bytes.len() as u64) {
std::fs::write(&out, &bytes).map_err(|e| e.to_string())?;
}
Ok(Some(out.to_string_lossy().to_string()))
})
.await
.map_err(|e| e.to_string())?
}
/// Open an http(s) URL in the system's default browser. The WebView blocks
/// `target="_blank"` navigation (no new-window handler is installed), so
/// external links such as artist socials route through