forked from Keelrleek/SoulStream
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:
1 parent
30eb6fe025
commit
50414dae5c
16 files changed
+4157
-154
No files matched your search
+201
-12
@@ -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
|
||||
|
||||
Reference in new issue
Block a user