Files
SoulStream/src-tauri/src/commands.rs
T

5513 lines
209 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager, State};
use soulseek_client::{Client, Config, DownloadState, DownloadUpdate, Event};
use soulseek_proto::types::SearchResponse;
use crate::config;
use crate::hero_crop::{maximal_hero_rect, plan_hero_crop, HeroCropPlan};
use crate::hero_detector::detect_subjects;
use crate::pipeline::{self, LibraryState};
use crate::playback::{self, PlayerState};
use crate::state::AppState;
#[derive(Serialize)]
pub struct AppStatus {
pub version: &'static str,
pub connected_as: Option<String>,
}
#[tauri::command]
pub async fn app_status(state: State<'_, AppState>) -> Result<AppStatus, String> {
Ok(AppStatus {
version: env!("CARGO_PKG_VERSION"),
connected_as: state.connected_as.lock().await.clone(),
})
}
/// The server's excluded-phrase list for query sanitizing; empty until the
/// server sends it after login. Frontend also receives it via the
/// `soulseek:excluded-phrases` event — this command covers a WebView reload
/// that happens after login already delivered the event.
#[tauri::command]
pub async fn excluded_search_phrases(state: State<'_, AppState>) -> Result<Vec<String>, String> {
Ok(state.excluded_search_phrases.lock().await.clone())
}
#[derive(Serialize)]
pub struct StoredCredentials {
pub username: String,
pub has_password: bool,
}
#[tauri::command]
pub fn get_credentials(app: tauri::AppHandle) -> StoredCredentials {
let cfg = config::load(&app);
let has_password = config::has_password(&cfg.username);
StoredCredentials {
username: cfg.username,
has_password,
}
}
/// Settings surfaced to the Settings view.
#[derive(Serialize)]
pub struct Settings {
pub username: String,
pub has_password: bool,
pub download_dir: String,
pub library_layout: String,
pub shared_folders: Vec<String>,
pub listen_port: Option<u16>,
pub upload_slots: u32,
pub connected: bool,
pub share_files: u32,
pub audio_output_device: Option<String>,
pub audio_output_devices: Vec<String>,
pub artist_hero_upscaler: config::ArtistHeroUpscaler,
pub replace_embedded_art: bool,
/// 0 = embed the original at full resolution.
pub embed_art_max_edge: u32,
pub write_folder_cover: bool,
pub stream_keep_forever: bool,
pub stream_retention_days: u32,
/// 0 = unlimited.
pub stream_cache_max_mb: u64,
pub stream_source_prefs: config::SourcePrefs,
pub download_source_prefs: config::SourcePrefs,
pub volume_normalization: bool,
/// Word-by-word lyric fill — experimental, default off.
pub word_timing: bool,
/// Dark-glass acrylic backdrop, default off.
pub glass_mode: bool,
}
#[tauri::command]
pub async fn get_settings(
app: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<Settings, String> {
let cfg = config::load(&app);
let share_files = state
.client
.lock()
.await
.as_ref()
.map(|c| c.share_counts().1)
.unwrap_or(0);
Ok(Settings {
has_password: config::has_password(&cfg.username),
download_dir: config::download_dir(&app, &cfg)
.to_string_lossy()
.into_owned(),
library_layout: config::library_layout(&cfg).to_string(),
shared_folders: cfg.shared_folders.clone(),
listen_port: cfg.listen_port,
upload_slots: cfg.upload_slots.unwrap_or(2),
connected: state.connected_as.lock().await.is_some(),
share_files,
audio_output_device: cfg.audio_output_device.clone(),
audio_output_devices: player::output_device_names().unwrap_or_default(),
artist_hero_upscaler: cfg.artist_hero_upscaler,
replace_embedded_art: config::replace_embedded_art(&cfg),
embed_art_max_edge: config::embed_art_max_edge(&cfg),
write_folder_cover: config::write_folder_cover(&cfg),
stream_keep_forever: config::stream_keep_forever(&cfg),
stream_retention_days: config::stream_retention_days(&cfg),
stream_cache_max_mb: config::stream_cache_max_mb(&cfg),
stream_source_prefs: cfg.stream_source_prefs,
download_source_prefs: cfg.download_source_prefs,
volume_normalization: config::volume_normalization(&cfg),
word_timing: config::word_timing(&cfg),
glass_mode: config::glass_mode(&cfg),
username: cfg.username,
})
}
/// Persist non-secret settings. Some (download dir, port) apply on next
/// connect; shared folders re-index live if connected.
#[tauri::command]
pub async fn set_settings(
app: tauri::AppHandle,
state: State<'_, AppState>,
players: State<'_, PlayerState>,
download_dir: Option<String>,
library_layout: Option<String>,
shared_folders: Option<Vec<String>>,
listen_port: Option<u16>,
upload_slots: Option<u32>,
audio_output_device: Option<String>,
artist_hero_upscaler: Option<config::ArtistHeroUpscaler>,
replace_embedded_art: Option<bool>,
embed_art_max_edge: Option<u32>,
write_folder_cover: Option<bool>,
stream_keep_forever: Option<bool>,
stream_retention_days: Option<u32>,
stream_cache_max_mb: Option<u64>,
stream_source_prefs: Option<config::SourcePrefs>,
download_source_prefs: Option<config::SourcePrefs>,
volume_normalization: Option<bool>,
word_timing: Option<bool>,
glass_mode: Option<bool>,
) -> Result<(), String> {
let mut cfg = config::load(&app);
let library_folder_moved = download_dir
.as_deref()
.is_some_and(|d| Some(d) != cfg.download_dir.as_deref());
if let Some(d) = download_dir {
cfg.download_dir = Some(d);
}
if let Some(layout) = library_layout {
// Separators are allowed: they create subfolders below the library
// root. organize_download sanitizes every rendered segment, so a
// layout (or tag value) cannot escape the root.
cfg.library_layout = Some(layout);
}
if let Some(f) = shared_folders {
cfg.shared_folders = f;
}
if listen_port.is_some() {
cfg.listen_port = listen_port;
}
if let Some(s) = upload_slots {
cfg.upload_slots = Some(s);
}
if let Some(device) = audio_output_device {
let device = match device.trim() {
"" => None,
name => Some(name.to_string()),
};
// Open first; only persist a choice that can actually be used.
players.player.set_output_device(device.clone())?;
cfg.audio_output_device = device;
}
if let Some(upscaler) = artist_hero_upscaler {
cfg.artist_hero_upscaler = upscaler;
}
if let Some(prefs) = stream_source_prefs {
cfg.stream_source_prefs = prefs;
}
if let Some(prefs) = download_source_prefs {
cfg.download_source_prefs = prefs;
}
if replace_embedded_art.is_some() {
cfg.replace_embedded_art = replace_embedded_art;
}
if embed_art_max_edge.is_some() {
cfg.embed_art_max_edge = embed_art_max_edge;
}
if write_folder_cover.is_some() {
cfg.write_folder_cover = write_folder_cover;
}
let retention_changed = stream_keep_forever.is_some()
|| stream_retention_days.is_some()
|| stream_cache_max_mb.is_some();
if stream_keep_forever.is_some() {
cfg.stream_keep_forever = stream_keep_forever;
}
if stream_retention_days.is_some() {
cfg.stream_retention_days = stream_retention_days;
}
if stream_cache_max_mb.is_some() {
cfg.stream_cache_max_mb = stream_cache_max_mb;
}
if let Some(enabled) = volume_normalization {
cfg.volume_normalization = Some(enabled);
// Applies live: off glides the current track back to unity; on
// takes effect from the next track.
crate::loudness::set_enabled(&app, enabled);
}
if word_timing.is_some() {
cfg.word_timing = word_timing;
}
if glass_mode.is_some() {
cfg.glass_mode = glass_mode;
}
config::save(&app, &cfg);
// A new library folder is a new library. Re-base immediately rather than
// at the next launch, so what the shelves show is what the folder holds —
// the old behaviour left the previous folder's albums standing forever,
// and emptying the new folder could not remove them.
if library_folder_moved {
let app2 = app.clone();
let root = config::download_dir(&app, &cfg);
tauri::async_runtime::spawn(async move {
let lib = app2.state::<crate::pipeline::LibraryState>().library.clone();
if let Ok(Ok(dropped)) =
tauri::async_runtime::spawn_blocking(move || lib.prune_outside_root(&root)).await
{
log::info!("library folder changed: dropped {dropped} tracks from the old folder");
}
let _ = crate::pipeline::rescan_library(app2.clone()).await;
let _ = app2.emit("soulseek:library-changed", ());
});
}
// Tighter limits take effect right away, not at the next stream/startup.
if retention_changed {
let app2 = app.clone();
tauri::async_runtime::spawn_blocking(move || crate::playback::prune_cache(&app2));
}
// Re-index shares live so the change takes effect without reconnecting.
if let Some(client) = state.client.lock().await.as_ref() {
let dl = config::download_dir(&app, &cfg);
let mut folders = vec![dl];
folders.extend(cfg.shared_folders.iter().map(std::path::PathBuf::from));
let (fdirs, ffiles) = client.update_shares(folders).await;
let _ = app.emit(
"soulseek:shares",
serde_json::json!({ "folders": fdirs, "files": ffiles }),
);
}
Ok(())
}
/// Forget stored credentials (config + credential manager).
#[tauri::command]
pub fn forget_credentials(app: tauri::AppHandle) -> Result<(), String> {
let mut cfg = config::load(&app);
config::delete_password(&cfg.username);
cfg.username = String::new();
config::save(&app, &cfg);
Ok(())
}
/// Open a native folder picker; returns the chosen path or null.
#[tauri::command]
pub async fn pick_folder(app: tauri::AppHandle) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog().file().pick_folder(move |path| {
let _ = tx.send(path.map(|p| p.to_string()));
});
Ok(rx.await.unwrap_or(None))
}
/// What the frontend sees for one search response, flattened per file
/// grouping done client-side (UI groups by user + folder).
#[derive(Serialize, Clone)]
pub struct SearchResultPayload {
pub token: u32,
pub username: String,
pub has_free_upload_slot: bool,
pub upload_speed: u32,
pub queue_length: u32,
pub files: Vec<FilePayload>,
}
#[derive(Serialize, Clone)]
pub struct FilePayload {
pub path: String,
pub name: String,
pub folder: String,
pub size: u64,
pub extension: String,
pub bitrate: Option<u32>,
pub duration: Option<u32>,
pub sample_rate: Option<u32>,
pub bit_depth: Option<u32>,
pub vbr: bool,
}
/// Broad queries match entire shared folders; a single response can list
/// thousands of files. The UI shows folder groups (500 files per group max),
/// so forwarding more than this per response only burns serialization,
/// IPC and webview memory.
const FILES_PER_RESPONSE_MAX: usize = 100;
fn to_payload(token: u32, r: &SearchResponse) -> SearchResultPayload {
SearchResultPayload {
token,
username: r.username.clone(),
has_free_upload_slot: r.has_free_upload_slot,
upload_speed: r.upload_speed,
queue_length: r.queue_length,
files: r
.files
.iter()
.take(FILES_PER_RESPONSE_MAX)
.map(|f| {
let (folder, name) = match f.filename.rsplit_once('\\') {
Some((d, n)) => (d.to_string(), n.to_string()),
None => (String::new(), f.filename.clone()),
};
// Many clients leave the extension attribute empty;
// fall back to the filename suffix.
let extension = if f.extension.is_empty() {
name.rsplit_once('.')
.map(|(_, e)| e.to_string())
.unwrap_or_default()
} else {
f.extension.clone()
};
FilePayload {
path: f.filename.clone(),
name,
folder,
size: f.size,
extension: extension.to_lowercase(),
bitrate: f.bitrate(),
duration: f.duration_secs(),
sample_rate: f.sample_rate(),
bit_depth: f.bit_depth(),
vbr: f.is_vbr(),
}
})
.collect(),
}
}
#[derive(Serialize, Clone)]
#[serde(tag = "kind", rename_all = "kebab-case")]
enum UiEvent {
Connected { username: String },
Disconnected { reason: String },
}
/// One download's status, as the frontend sees it.
#[derive(Serialize, Clone)]
pub struct DownloadPayload {
pub id: u64,
pub username: String,
pub path: String,
pub name: String,
pub state: String,
pub queue_place: Option<u32>,
pub local_path: Option<String>,
pub error: Option<String>,
pub received: u64,
pub total: u64,
pub speed_bps: u64,
}
fn download_payload(u: &DownloadUpdate) -> DownloadPayload {
let name = u
.filename
.rsplit('\\')
.next()
.unwrap_or(&u.filename)
.to_string();
let (state, queue_place, local_path, error) = match &u.state {
DownloadState::Connecting => ("connecting", None, None, None),
DownloadState::Requested => ("requested", None, None, None),
DownloadState::Queued { place } => ("queued", *place, None, None),
DownloadState::Transferring => ("transferring", None, None, None),
DownloadState::Complete { local_path } => {
("complete", None, Some(local_path.clone()), None)
}
DownloadState::Failed { reason } => ("failed", None, None, Some(reason.clone())),
DownloadState::Cancelled => ("cancelled", None, None, None),
};
DownloadPayload {
id: u.id,
username: u.username.clone(),
path: u.filename.clone(),
name,
state: state.into(),
queue_place,
local_path,
error,
received: u.received,
total: u.total,
speed_bps: u.speed_bps,
}
}
/// Connect using credentials saved in the config file, without exposing
/// the password to the webview. Errors if none are stored.
#[tauri::command]
pub async fn connect_stored(
app: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<String, String> {
let cfg = config::load(&app);
let password = config::load_password(&cfg.username);
match (cfg.username.is_empty(), password) {
(false, Some(pw)) => connect_and_login(app, state, cfg.username, pw, false).await,
_ => Err("no stored credentials".into()),
}
}
#[tauri::command]
pub async fn connect_and_login(
app: tauri::AppHandle,
state: State<'_, AppState>,
username: String,
password: String,
remember: bool,
) -> Result<String, String> {
// Drop any previous session first.
if let Some(old) = state.client.lock().await.take() {
old.shutdown();
}
*state.connected_as.lock().await = None;
let stored = config::load(&app);
let mut cfg = Config::default();
if let Some(port) = stored.listen_port {
cfg.listen_port = port;
}
if let Some(slots) = stored.upload_slots {
cfg.upload_slots = slots;
}
// Configured download target, or the <Music> default.
let downloads = config::download_dir(&app, &stored);
cfg.downloads_dir = downloads.clone();
// Be a good citizen: share everything we've downloaded, plus any extra
// configured folders.
std::fs::create_dir_all(&downloads).ok();
let mut shares = vec![downloads];
shares.extend(stored.shared_folders.iter().map(std::path::PathBuf::from));
cfg.shared_folders = shares;
cfg.description = "Shared with SoulStream".into();
// The previous session's listener releases its port a moment after
// shutdown; retry briefly instead of failing with WSAEADDRINUSE.
let (client, mut events) = {
let mut attempt = 0;
loop {
match Client::connect(cfg.clone()).await {
Ok(pair) => break pair,
Err(e) if attempt < 8 && e.to_string().contains("10048") => {
attempt += 1;
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
Err(e) => return Err(format!("could not reach server: {e}")),
}
}
};
let greeting = client
.login(&username, &password)
.await
.map_err(|e| e.to_string())?;
if remember {
let mut save = stored.clone();
save.username = username.clone();
config::save(&app, &save);
config::save_password(&username, &password);
}
// Report share counts to the UI once indexed.
let (folders, files) = client.share_counts();
let _ = app.emit(
"soulseek:shares",
serde_json::json!({ "folders": folders, "files": files }),
);
// Watch the buddy list so status/stats stream in for the Users tab.
for buddy in &config::load(&app).buddies {
let _ = client.watch_user(buddy);
}
*state.client.lock().await = Some(client);
*state.connected_as.lock().await = Some(username.clone());
// Forward client events to the webview for the life of this session.
let event_app = app.clone();
let event_user = username.clone();
tauri::async_runtime::spawn(async move {
let _ = event_app.emit(
"soulseek:state",
UiEvent::Connected {
username: event_user,
},
);
// Search responses arrive in the thousands within seconds on broad
// queries. Emitting each one individually floods the IPC bridge and
// the webview's event loop (UI lockups, eventually OOM), so buffer
// them and flush as one batch per tick. A per-token cap bounds the
// total work a single search can ever create.
const SEARCH_FLUSH_MS: u64 = 150;
const SEARCH_BATCH_MAX: usize = 100;
const SEARCH_RESPONSES_MAX: u32 = 1500;
const SEARCH_FILES_MAX: u32 = 20_000;
let mut search_buf: Vec<SearchResultPayload> = Vec::new();
// token → (responses forwarded, files forwarded)
let mut sent_counts: std::collections::HashMap<u32, (u32, u32)> =
std::collections::HashMap::new();
let mut flush = tokio::time::interval(std::time::Duration::from_millis(SEARCH_FLUSH_MS));
flush.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
let ev = tokio::select! {
maybe_ev = events.recv() => match maybe_ev {
Some(ev) => ev,
None => break,
},
_ = flush.tick() => {
if !search_buf.is_empty() {
let _ = event_app
.emit("soulseek:search-results", std::mem::take(&mut search_buf));
}
continue;
}
};
match ev {
Event::SearchResult { token, response } => {
let (responses, files) = sent_counts.entry(token).or_insert((0, 0));
if *responses >= SEARCH_RESPONSES_MAX || *files >= SEARCH_FILES_MAX {
continue;
}
*responses += 1;
let payload = to_payload(token, &response);
*files += payload.files.len() as u32;
search_buf.push(payload);
if search_buf.len() >= SEARCH_BATCH_MAX {
let _ = event_app
.emit("soulseek:search-results", std::mem::take(&mut search_buf));
}
}
Event::Download(update) => {
// Next-track preloads are invisible: no player, no
// Downloads view, no library pipeline. On completion,
// tell the frontend so it can append the cached track
// to the sink for a gapless transition.
if let Some(ps) = event_app.try_state::<PlayerState>() {
let is_preload = ps.preloads.lock().unwrap().contains_key(&update.id);
if is_preload {
use soulseek_client::DownloadState as DS;
let terminal = matches!(
&update.state,
DS::Complete { .. } | DS::Failed { .. } | DS::Cancelled
);
// Mirror every update for the debug overlay's
// QUEUE section — these transfers are otherwise
// invisible by design.
let state_label = match &update.state {
DS::Connecting => "connecting".to_string(),
DS::Requested => "requested".to_string(),
DS::Queued { .. } => "queued".to_string(),
DS::Transferring => "transferring".to_string(),
DS::Complete { .. } => "done".to_string(),
DS::Failed { reason } => format!("failed: {reason}"),
DS::Cancelled => "cancelled".to_string(),
};
let place = match &update.state {
DS::Queued { place } => *place,
_ => None,
};
crate::diagnostics::note_preload(
&event_app,
update.id,
crate::diagnostics::PreloadDl {
username: update.username.clone(),
path: update.filename.clone(),
state: state_label,
place,
received: update.received,
total: update.total,
speed_bps: update.speed_bps,
},
terminal,
);
if terminal {
let cache = ps.preloads.lock().unwrap().remove(&update.id);
let save_cache =
ps.deferred_saves.lock().unwrap().remove(&update.id);
let _ = event_app.emit(
"soulseek:preload",
serde_json::json!({
"username": update.username,
"path": update.filename,
"done": matches!(&update.state, DS::Complete { .. }),
}),
);
if matches!(&update.state, DS::Complete { .. }) {
// Gapless normalization: measure the next
// track now so its gain is ready at the
// boundary.
if let Some(c) = &cache {
crate::loudness::on_preload_ready(&event_app, c);
}
// Only explicit saves reach the library —
// unless streamed tracks are configured to
// be kept forever.
let keep =
config::stream_keep_forever(&config::load(&event_app));
let save = if keep {
save_cache.or(cache)
} else {
save_cache
};
let app2 = event_app.clone();
let username = update.username.clone();
let remote = update.filename.clone();
tauri::async_runtime::spawn(async move {
if let Some(cache) = save {
let downloads_dir =
config::download_dir(&app2, &config::load(&app2));
if let Err(err) = organize_cached_save(
&app2,
&downloads_dir,
&cache,
&remote,
)
.await
{
log::warn!("could not save completed preload {username}\\{remote}: {err}");
}
let _ = app2.emit("soulseek:library-changed", ());
}
// The cache just grew; enforce retention
// after any save copy is done with it.
let app3 = app2.clone();
let _ = tauri::async_runtime::spawn_blocking(move || {
crate::playback::prune_cache(&app3)
})
.await;
});
}
}
continue;
}
}
// Stream downloads feed the player instead of the
// Downloads view.
let stream = event_app
.try_state::<PlayerState>()
.and_then(|ps| ps.streams.lock().unwrap().get(&update.id).cloned());
if let Some(progress) = stream {
use soulseek_client::DownloadState as DS;
let (done, failed) = match &update.state {
DS::Complete { .. } => (true, None),
DS::Failed { reason } => (false, Some(reason.clone())),
DS::Cancelled => (false, Some("stream cancelled".to_string())),
_ => (false, None),
};
progress.update(update.received, done, failed.clone());
let _ = event_app.emit(
"soulseek:stream",
serde_json::json!({
"received": update.received,
"total": update.total,
"done": done,
"failed": failed,
}),
);
if done || failed.is_some() {
if let Some(ps) = event_app.try_state::<PlayerState>() {
ps.streams.lock().unwrap().remove(&update.id);
let cache = ps.stream_caches.lock().unwrap().remove(&update.id);
let save_cache =
ps.deferred_saves.lock().unwrap().remove(&update.id);
if done {
// Streams stay cache-only unless the user
// saved this track or retention is set to
// keep streamed tracks forever.
let keep =
config::stream_keep_forever(&config::load(&event_app));
let save = if keep {
save_cache.or(cache)
} else {
save_cache
};
let app2 = event_app.clone();
let username = update.username.clone();
let remote = update.filename.clone();
tauri::async_runtime::spawn(async move {
if let Some(cache) = save {
let downloads_dir =
config::download_dir(&app2, &config::load(&app2));
if let Err(err) = organize_cached_save(
&app2,
&downloads_dir,
&cache,
&remote,
)
.await
{
log::warn!("could not save completed stream {username}\\{remote}: {err}");
}
let _ = app2.emit("soulseek:library-changed", ());
}
// The cache just grew; enforce retention
// after any save copy is done with it.
let app3 = app2.clone();
let _ = tauri::async_runtime::spawn_blocking(move || {
crate::playback::prune_cache(&app3)
})
.await;
});
}
}
}
} else if crate::import::is_audition(update.id) {
// Audition downloads are candidate versions under
// review in the (Cache) pen — they stay OUT of the
// library pipeline; the import window watches them.
use soulseek_client::DownloadState as DS;
// WHICH record and WHICH track, straight from the
// registry — looked up before audition_done retires
// the id. The UI matches on this, never on the id:
// the first events of a transfer are emitted before
// import_audition has returned its ids at all.
let (au_rel, au_idx) = crate::import::audition_of(update.id)
.map(|(r, i)| (r, i as i64))
.unwrap_or_else(|| (String::new(), -1));
match &update.state {
DS::Complete { local_path } => {
let _ = event_app.emit(
"import:audition",
serde_json::json!({
"id": update.id,
"rel": au_rel,
"idx": au_idx,
"localPath": local_path,
"remote": update.filename,
"done": true,
}),
);
crate::import::audition_done(update.id);
}
DS::Failed { reason } => {
let _ = event_app.emit(
"import:audition",
serde_json::json!({
"id": update.id,
"rel": au_rel,
"idx": au_idx,
"remote": update.filename,
"failed": reason,
}),
);
crate::import::audition_done(update.id);
}
DS::Cancelled => {
crate::import::audition_done(update.id);
}
// In flight. The picker's row can only say what it
// is told, and "arriving…" was all it had: the
// phase, the QUEUE PLACE and the speed were all
// known here and thrown away. A place that ticks
// down is the difference between waiting and
// wondering whether anything is happening.
rest => {
let (phase, place) = match rest {
DS::Connecting => ("connecting", None),
DS::Requested => ("requested", None),
DS::Queued { place } => ("queued", *place),
DS::Transferring => ("transferring", None),
_ => ("working", None),
};
let _ = event_app.emit(
"import:audition",
serde_json::json!({
"id": update.id,
"rel": au_rel,
"idx": au_idx,
"remote": update.filename,
"received": update.received,
"total": update.total,
"speedBps": update.speed_bps,
"phase": phase,
"place": place,
}),
);
}
}
} else {
let _ = event_app.emit("soulseek:download", download_payload(&update));
// Completed real downloads go through the tag/art
// pipeline and into the library.
if let soulseek_client::DownloadState::Complete { local_path } =
&update.state
{
let app2 = event_app.clone();
let local = std::path::PathBuf::from(local_path.clone());
let remote = update.filename.clone();
tauri::async_runtime::spawn(async move {
pipeline::process_file(&app2, &local, Some(&remote)).await;
});
}
}
}
Event::Disconnected { reason } => {
let _ = event_app.emit("soulseek:state", UiEvent::Disconnected { reason });
if let Some(st) = event_app.try_state::<AppState>() {
*st.connected_as.lock().await = None;
}
break;
}
Event::ExcludedSearchPhrases(p) => {
log::info!("server sent {} excluded search phrases: {:?}", p.len(), p);
let lowered: Vec<String> = p.iter().map(|s| s.to_lowercase()).collect();
if let Some(st) = event_app.try_state::<AppState>() {
*st.excluded_search_phrases.lock().await = lowered.clone();
}
// The resolver builds queries frontend-side; hand it the
// list so poisoned terms never reach the wire.
let _ = event_app.emit("soulseek:excluded-phrases", lowered);
}
Event::Upload(u) => {
use soulseek_client::UploadState as US;
let state = match &u.state {
US::Queued => "queued",
US::Transferring => "transferring",
US::Complete => "complete",
US::Failed { .. } => "failed",
};
let _ = event_app.emit(
"soulseek:upload",
serde_json::json!({
"id": u.id,
"username": u.username,
"name": u.filename.rsplit('\\').next().unwrap_or(&u.filename),
// Full shared path too — Transfers shows the
// parent folder ahead of the name, Qt-style.
"path": u.filename,
"state": state,
"sent": u.sent,
"total": u.total,
}),
);
}
Event::RespondedToSearch { username, files } => {
let _ = event_app.emit(
"soulseek:responded",
serde_json::json!({ "username": username, "files": files }),
);
}
// ---- the Soulseek hub's seats: rooms, chat, users ----
Event::RoomList { rooms } => {
let rooms: Vec<_> = rooms
.into_iter()
.map(|(name, users)| serde_json::json!({ "name": name, "users": users }))
.collect();
let _ = event_app.emit("soulseek:room-list", rooms);
}
Event::RoomJoined { room, users } => {
let _ = event_app.emit(
"soulseek:room-joined",
serde_json::json!({ "room": room, "users": room_users_json(&users) }),
);
}
Event::RoomLeft { room } => {
let _ = event_app.emit("soulseek:room-left", serde_json::json!({ "room": room }));
}
Event::RoomUserJoined { room, user } => {
let _ = event_app.emit(
"soulseek:room-user",
serde_json::json!({
"room": room,
"joined": true,
"user": room_user_json(&user),
}),
);
}
Event::RoomUserLeft { room, username } => {
let _ = event_app.emit(
"soulseek:room-user",
serde_json::json!({ "room": room, "joined": false, "username": username }),
);
}
Event::RoomMessage {
room,
username,
message,
} => {
let _ = event_app.emit(
"soulseek:room-message",
serde_json::json!({
"room": room,
"username": username,
"message": message,
"ts": now_ms(),
}),
);
}
Event::PrivateMessage {
id,
timestamp,
username,
message,
} => {
// Identity-link handshakes ride private messages and
// never reach the Inbox (slsk_link.rs).
if crate::slsk_link::intercept_pm(&event_app, &username, &message).await
|| crate::friends::intercept_pm(&event_app, &username, &message)
{
continue;
}
let _ = event_app.emit(
"soulseek:private-message",
serde_json::json!({
"id": id,
"timestamp": timestamp,
"username": username,
"message": message,
}),
);
}
Event::UserWatched {
username,
exists,
status,
avg_speed,
file_count,
dir_count,
} => {
let _ = event_app.emit(
"soulseek:user-state",
serde_json::json!({
"kind": "watched",
"username": username,
"exists": exists,
"status": status,
"avg_speed": avg_speed,
"file_count": file_count,
"dir_count": dir_count,
}),
);
}
Event::UserStatus {
username, status, ..
} => {
let _ = event_app.emit(
"soulseek:user-state",
serde_json::json!({ "kind": "status", "username": username, "status": status }),
);
}
Event::UserStats {
username,
avg_speed,
file_count,
dir_count,
} => {
let _ = event_app.emit(
"soulseek:user-state",
serde_json::json!({
"kind": "stats",
"username": username,
"avg_speed": avg_speed,
"file_count": file_count,
"dir_count": dir_count,
}),
);
}
_ => {}
}
}
});
Ok(greeting)
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
fn room_user_json(u: &soulseek_client::RoomUser) -> serde_json::Value {
serde_json::json!({
"username": u.username,
"status": u.status,
"avg_speed": u.avg_speed,
"file_count": u.file_count,
"dir_count": u.dir_count,
})
}
fn room_users_json(users: &[soulseek_client::RoomUser]) -> Vec<serde_json::Value> {
users.iter().map(room_user_json).collect()
}
// ---- the Soulseek hub: rooms / chat / users / browse commands ----
/// Briefly lock the client, run `f`, release. Every hub command is a small
/// synchronous send; only browse runs long, and it detaches first.
async fn with_client<T>(
state: &State<'_, AppState>,
f: impl FnOnce(&soulseek_client::Client) -> soulseek_client::Result<T>,
) -> Result<T, String> {
let guard = state.client.lock().await;
let client = guard.as_ref().ok_or("not connected to Soulseek")?;
f(client).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn slsk_join_room(state: State<'_, AppState>, room: String) -> Result<(), String> {
with_client(&state, |c| c.join_room(&room)).await
}
#[tauri::command]
pub async fn slsk_leave_room(state: State<'_, AppState>, room: String) -> Result<(), String> {
with_client(&state, |c| c.leave_room(&room)).await
}
#[tauri::command]
pub async fn slsk_say_room(
state: State<'_, AppState>,
room: String,
message: String,
) -> Result<(), String> {
let message = message.trim().to_string();
if message.is_empty() {
return Ok(());
}
with_client(&state, |c| c.say_room(&room, &message)).await
}
#[tauri::command]
pub async fn slsk_message_user(
state: State<'_, AppState>,
username: String,
message: String,
) -> Result<(), String> {
let message = message.trim().to_string();
if message.is_empty() {
return Ok(());
}
with_client(&state, |c| c.message_user(&username, &message)).await
}
#[tauri::command]
pub async fn slsk_room_list(state: State<'_, AppState>) -> Result<(), String> {
with_client(&state, |c| c.request_room_list()).await
}
#[derive(serde::Serialize)]
pub struct BrowseFileDto {
pub name: String,
/// Full remote path (folder + name), ready for a download request.
pub path: String,
pub size: u64,
pub ext: String,
pub bitrate: Option<u32>,
pub duration: Option<u32>,
}
#[derive(serde::Serialize)]
pub struct BrowseDirDto {
pub name: String,
pub files: Vec<BrowseFileDto>,
}
/// Walk a user's shares. Long-running: the client lock is held only long
/// enough to start the request.
#[derive(Serialize)]
pub struct UserInfoDto {
pub username: String,
pub description: String,
/// The picture ONLY after the avatar pipeline's bounded decode + resize
/// (profile.rs): a peer's bytes are hostile input, never rendered raw.
pub picture_png_base64: Option<String>,
pub picture_note: Option<String>,
pub total_uploads: u32,
pub queue_size: u32,
pub has_free_slot: bool,
}
/// A peer's native user-info card: description, picture, upload state.
#[tauri::command]
pub async fn slsk_user_info(
state: State<'_, AppState>,
username: String,
) -> Result<UserInfoDto, String> {
let fut = {
let guard = state.client.lock().await;
let client = guard.as_ref().ok_or("not connected to Soulseek")?;
client.user_info(&username)
};
let info = fut.await.map_err(|e| e.to_string())?;
let (picture_png_base64, picture_note) = match info.picture.as_deref() {
None => (None, None),
Some(bytes) => match crate::profile::normalize_avatar(bytes) {
Ok(png) => {
use base64::Engine;
(Some(base64::engine::general_purpose::STANDARD.encode(png)), None)
}
Err(e) => (None, Some(format!("picture refused: {e}"))),
},
};
Ok(UserInfoDto {
username,
description: info.description,
picture_png_base64,
picture_note,
total_uploads: info.total_uploads,
queue_size: info.queue_size,
has_free_slot: info.has_free_slot,
})
}
#[derive(Serialize)]
pub struct InterestsDto {
pub likes: Vec<String>,
pub hates: Vec<String>,
}
/// A user's declared Soulseek interests — raw likes and hates, not follows.
#[tauri::command]
pub async fn slsk_user_interests(
state: State<'_, AppState>,
username: String,
) -> Result<InterestsDto, String> {
let fut = {
let guard = state.client.lock().await;
let client = guard.as_ref().ok_or("not connected to Soulseek")?;
client.user_interests(&username)
};
let (likes, hates) = fut.await.map_err(|e| e.to_string())?;
Ok(InterestsDto { likes, hates })
}
#[derive(Serialize)]
pub struct SimilarUserDto {
pub username: String,
pub rating: u32,
}
/// Users the server ranks as similar to us by shared interests.
#[tauri::command]
pub async fn slsk_similar_users(state: State<'_, AppState>) -> Result<Vec<SimilarUserDto>, String> {
let fut = {
let guard = state.client.lock().await;
let client = guard.as_ref().ok_or("not connected to Soulseek")?;
client.similar_users()
};
let users = fut.await.map_err(|e| e.to_string())?;
Ok(users
.into_iter()
.map(|(username, rating)| SimilarUserDto { username, rating })
.collect())
}
#[tauri::command]
pub async fn slsk_browse_user(
state: State<'_, AppState>,
username: String,
) -> Result<Vec<BrowseDirDto>, String> {
let fut = {
let guard = state.client.lock().await;
let client = guard.as_ref().ok_or("not connected to Soulseek")?;
client.browse(&username)
};
let dirs = fut.await.map_err(|e| e.to_string())?;
Ok(dirs
.into_iter()
.map(|d| {
let dir_name = d.name.clone();
BrowseDirDto {
files: d
.files
.into_iter()
.map(|f| {
let bitrate = f.bitrate();
let duration = f.duration_secs();
let ext = if f.extension.is_empty() {
f.filename.rsplit('.').next().unwrap_or_default().to_string()
} else {
f.extension.clone()
};
BrowseFileDto {
path: format!("{dir_name}\\{}", f.filename),
name: f.filename,
size: f.size,
ext,
bitrate,
duration,
}
})
.collect(),
name: dir_name,
}
})
.collect())
}
#[tauri::command]
pub fn buddy_list(app: tauri::AppHandle) -> Vec<String> {
config::load(&app).buddies
}
#[tauri::command]
pub async fn buddy_add(
app: tauri::AppHandle,
state: State<'_, AppState>,
username: String,
) -> Result<Vec<String>, String> {
let username = username.trim().to_string();
if username.is_empty() {
return Err("no username".into());
}
let mut cfg = config::load(&app);
if !cfg.buddies.iter().any(|b| b == &username) {
cfg.buddies.push(username.clone());
config::save(&app, &cfg);
}
// Watch immediately when connected; on later logins the connect path
// re-watches the whole list.
let _ = with_client(&state, |c| c.watch_user(&username)).await;
let _ = with_client(&state, |c| c.get_user_stats(&username)).await;
Ok(cfg.buddies)
}
#[tauri::command]
pub async fn buddy_remove(
app: tauri::AppHandle,
state: State<'_, AppState>,
username: String,
) -> Result<Vec<String>, String> {
let mut cfg = config::load(&app);
cfg.buddies.retain(|b| b != &username);
config::save(&app, &cfg);
let _ = with_client(&state, |c| c.unwatch_user(&username)).await;
Ok(cfg.buddies)
}
// ---- catalog browser (Deezer-backed) ----
const ARTIST_HERO_MIN_WIDTH: u32 = 1920;
const ARTIST_HERO_MIN_HEIGHT: u32 = 800;
const ARTIST_HERO_MAX_BYTES: u64 = 30 * 1024 * 1024;
const ARTIST_HERO_PROVIDER_TARGET_WIDTH: u32 = 3840;
const ARTIST_HERO_PROVIDER_TARGET_HEIGHT: u32 = 2160;
const ARTIST_HERO_PREVIEW_MAX_WIDTH: u32 = 1920;
const ARTIST_HERO_GPU_PREPARE_CONCURRENCY: usize = 2;
const ARTIST_HERO_FSR_SHARPNESS_STOPS: &str = "0.25";
const ARTIST_HERO_LUT_STRENGTH: f32 = 0.30;
const ARTIST_HERO_FADE_STRENGTH: f32 = 1.0;
const ARTIST_HERO_GRAIN_STRENGTH: f32 = 0.70;
/// Bump whenever any pixel-producing hero setting or processing order changes.
const ARTIST_HERO_CROP_RECIPE: &str = "vertical-yoloxnano-c789161e-windows-face-v2-faces-first";
const ARTIST_HERO_CACHE_RECIPE: &str =
"w3840h2160-jpeg93-fsr025-ruby030-fade100-grain070-contentcrop-v5";
const ARTIST_HERO_CACHE_TTL: std::time::Duration =
std::time::Duration::from_secs(30 * 24 * 60 * 60);
const ARTIST_HERO_CACHE_MAX_BYTES: u64 = 256 * 1024 * 1024;
const ARTIST_HERO_CACHE_MAX_ENTRY_BYTES: u64 = 41 * 1024 * 1024;
// v2 invalidated manifests written before provider artwork was required to
// close the loop back to the requested Deezer identity. v3 invalidates the
// window where an exact-name collision forced every Bing card to cite a
// catalogue release, which discarded the press photography that makes the best
// heroes; those degraded selections are pinned for a day otherwise. Bump this
// whenever hero *selection* changes, not merely its rendering. Keeping the same
// file path is safe: a recipe mismatch forces a fresh lookup and the successful
// result atomically replaces the stale manifest.
// v5: TheAudioDB records referencing a DUPLICATE MusicBrainz entry for the same
// artist are no longer rejected as namesakes, so artists cached under v4 are
// holding a logo-less manifest this build would now fill. Safe to bump: a
// stale-recipe manifest is kept as a floor and only replaced by a better
// lookup, so nobody goes blank while the new one resolves.
// v6: manifests written while Fanart.tv's CDN was refusing connections
// (2026-07-27) hold Bing-tier heroes and would have kept them for a month under
// the flat TTL below. Bumping re-resolves every artist once against the
// providers that are up TODAY; the tier-aware TTL keeps it from happening again.
const ARTIST_ART_MANIFEST_RECIPE: &str = "artist-art-tier-v6-fallback-ttl";
// A resolved hero is expensive to find and does not go stale the way metadata
// does -- a press photo is still the artist's press photo a month later. The
// old 24h expiry meant every artist was re-discovered daily through a tier that
// fails per-name, so a single bad day blanked artists that had perfectly good
// heroes: 26 manifests on disk (Crazy P, MSPAINT, Sword II, Hermeth, Billy Cobb,
// Pond, Weezer, Daft Punk...) went to 3. Match the cover-resolution horizon.
const ARTIST_ART_MANIFEST_TTL: std::time::Duration =
std::time::Duration::from_secs(30 * 24 * 60 * 60);
// ...but only for art that came from the TOP of the chain. Everything below it
// was a fallback taken because the better provider was unreachable at that
// moment -- `image_host_alive` deliberately reports a dead CDN as "returned
// nothing", which is what lets the chain degrade gracefully -- and a month is
// far too long to live with the consequences of one bad afternoon. Re-checked
// daily instead, so an artist stops wearing a Bing hero the day after Fanart.tv
// comes back.
const ARTIST_ART_FALLBACK_TTL: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
/// Did the top provider tier supply the banner this manifest will actually
/// show? The manifest never recorded which provider won, so the tier is read
/// back off the URL's host.
///
/// Banners only, deliberately: logos routinely and legitimately come from
/// TheAudioDB even when Fanart.tv covered the artist, and treating that as a
/// fallback would put every artist on a daily re-check. An artist Fanart.tv
/// simply does not cover is re-checked daily forever, which is the price of
/// noticing the day they add one.
fn manifest_from_top_tier(art: &metadata::CatalogArtistArt) -> bool {
art.banners.iter().any(|url| {
// Every stored URL is validated as `https://...` before it gets here,
// so the authority is simply what sits between the scheme and the
// first slash (minus any port).
url.strip_prefix("https://")
.and_then(|rest| rest.split('/').next())
.map(|authority| authority.split(':').next().unwrap_or(authority))
.is_some_and(|host| host == "fanart.tv" || host.ends_with(".fanart.tv"))
})
}
const ARTIST_ART_MANIFEST_MAX_BYTES: u64 = 64 * 1024;
// Identity is part of the key because two Deezer artists can share the same
// display name (and, occasionally, the same release title/reference artwork).
// The resolver's Apple candidate selection is artist-ID scoped as of v2.
const COVER_RESOLUTION_RECIPE: &str = "same-art-dhash10-v2-identity";
const COVER_RESOLUTION_TTL: std::time::Duration = std::time::Duration::from_secs(30 * 24 * 60 * 60);
const COVER_RESOLUTION_CACHE_MAX_FILES: usize = 4096;
const ARTIST_ART_MANIFEST_CACHE_MAX_FILES: usize = 512;
const PERSISTENT_CACHE_SWEEP_INTERVAL_SECS: u64 = 60 * 60;
fn artist_hero_gpu_prepare_slots() -> &'static tokio::sync::Semaphore {
static SLOTS: std::sync::OnceLock<tokio::sync::Semaphore> = std::sync::OnceLock::new();
SLOTS.get_or_init(|| tokio::sync::Semaphore::new(ARTIST_HERO_GPU_PREPARE_CONCURRENCY))
}
#[derive(Serialize, Deserialize)]
struct CachedArtistArtManifest {
recipe: String,
artist_id: u64,
art: metadata::CatalogArtistArt,
}
#[derive(Serialize, Deserialize)]
struct CachedCoverResolution {
recipe: String,
best_url: String,
}
fn maybe_sweep_cache_directory(
directory: &std::path::Path,
ttl: std::time::Duration,
max_files: usize,
last_sweep: &std::sync::atomic::AtomicU64,
file_count: &std::sync::atomic::AtomicUsize,
sweep_in_progress: &std::sync::atomic::AtomicBool,
) {
use std::sync::atomic::Ordering;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0);
let previous = last_sweep.load(Ordering::Relaxed);
let sweep_due = now.saturating_sub(previous) >= PERSISTENT_CACHE_SWEEP_INTERVAL_SECS;
let at_capacity = file_count.load(Ordering::Relaxed) >= max_files;
if (!sweep_due && !at_capacity)
|| sweep_in_progress
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_err()
{
return;
}
last_sweep.store(now, Ordering::Relaxed);
let Ok(entries) = std::fs::read_dir(directory) else {
sweep_in_progress.store(false, Ordering::Release);
return;
};
let mut retained = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
let Ok(metadata) = entry.metadata() else {
continue;
};
if !metadata.is_file() {
continue;
}
let modified = metadata.modified().unwrap_or(std::time::UNIX_EPOCH);
let expired = modified.elapsed().is_ok_and(|age| age >= ttl);
if expired {
let _ = std::fs::remove_file(path);
} else {
retained.push((modified, path));
}
}
// This runs immediately before a write. Prune to a 90% low-water mark:
// leaving only one slot would force a 4K-entry directory scan + sort on
// every subsequent cover write once the cache first reached capacity.
let retained_limit = max_files
.saturating_mul(9)
.checked_div(10)
.unwrap_or(0)
.min(max_files.saturating_sub(1));
let mut removed = 0usize;
if retained.len() > retained_limit {
retained.sort_unstable_by_key(|(modified, _)| *modified);
removed = retained.len() - retained_limit;
for (_, path) in retained.iter().take(removed) {
let _ = std::fs::remove_file(path);
}
}
file_count.store(retained.len() - removed, Ordering::Relaxed);
sweep_in_progress.store(false, Ordering::Release);
}
fn cover_resolution_cache_path(
app: &tauri::AppHandle,
artist_id: Option<u64>,
artist: &str,
title: &str,
cover: &str,
) -> Result<std::path::PathBuf, String> {
use std::hash::{Hash as _, Hasher as _};
let dir = app
.path()
.app_cache_dir()
.map_err(|error| error.to_string())?
.join("cover-resolutions-v1");
std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
let mut key = std::collections::hash_map::DefaultHasher::new();
COVER_RESOLUTION_RECIPE.hash(&mut key);
artist_id.hash(&mut key);
artist.hash(&mut key);
title.hash(&mut key);
cover.hash(&mut key);
Ok(dir.join(format!("{:016x}.json", key.finish())))
}
async fn load_cover_resolution(
app: &tauri::AppHandle,
artist_id: Option<u64>,
artist: &str,
title: &str,
cover: &str,
) -> Option<String> {
let path = cover_resolution_cache_path(app, artist_id, artist, title, cover).ok()?;
let metadata = tokio::fs::metadata(&path).await.ok()?;
let fresh = metadata
.modified()
.ok()
.and_then(|modified| modified.elapsed().ok())
.is_some_and(|age| age < COVER_RESOLUTION_TTL);
if !fresh || metadata.len() > 4096 {
let _ = tokio::fs::remove_file(path).await;
return None;
}
let cached =
serde_json::from_slice::<CachedCoverResolution>(&tokio::fs::read(path).await.ok()?).ok()?;
(cached.recipe == COVER_RESOLUTION_RECIPE && cached.best_url.starts_with("https://"))
.then_some(cached.best_url)
}
fn store_cover_resolution(path: &std::path::Path, best_url: &str) -> Result<(), String> {
static LAST_SWEEP: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static FILE_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
static SWEEP_IN_PROGRESS: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
if let Some(directory) = path.parent() {
maybe_sweep_cache_directory(
directory,
COVER_RESOLUTION_TTL,
COVER_RESOLUTION_CACHE_MAX_FILES,
&LAST_SWEEP,
&FILE_COUNT,
&SWEEP_IN_PROGRESS,
);
}
let replaces_existing = path.is_file();
let cached = CachedCoverResolution {
recipe: COVER_RESOLUTION_RECIPE.to_string(),
best_url: best_url.to_string(),
};
let bytes = serde_json::to_vec(&cached).map_err(|error| error.to_string())?;
let temporary = path.with_extension(format!("{}.tmp", std::process::id()));
std::fs::write(&temporary, bytes).map_err(|error| error.to_string())?;
if let Err(first_error) = std::fs::rename(&temporary, path) {
let _ = std::fs::remove_file(path);
if let Err(retry_error) = std::fs::rename(&temporary, path) {
let _ = std::fs::remove_file(&temporary);
return Err(format!(
"cover resolution cache rename failed ({first_error}); replacement failed ({retry_error})"
));
}
}
if !replaces_existing {
FILE_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
Ok(())
}
fn artist_art_manifest_path(
app: &tauri::AppHandle,
artist_id: u64,
) -> Result<std::path::PathBuf, String> {
let dir = app
.path()
.app_cache_dir()
.map_err(|error| error.to_string())?
.join("artist-art-manifests-v1");
std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
Ok(dir.join(format!("{artist_id}.json")))
}
/// A persisted manifest that passed structural validation, plus whether it can
/// be served without asking the network again.
struct StoredArtistArt {
art: metadata::CatalogArtistArt,
/// Fresh enough AND written by the current selection recipe.
current: bool,
}
/// Read a persisted manifest without destroying it.
///
/// This deliberately does NOT delete stale or older-recipe manifests. It used
/// to, and that is what blanked artists: the file was removed on the way in,
/// then discovery failed, and an artist that had a perfectly good hero five
/// minutes earlier had nothing at all. A known hero is only ever replaced by a
/// better one now -- see `catalog_artist_art`. Only genuinely unusable files
/// (oversized or unparseable) are removed.
async fn read_artist_art_manifest(
app: &tauri::AppHandle,
artist_id: u64,
) -> Option<StoredArtistArt> {
let path = artist_art_manifest_path(app, artist_id).ok()?;
let metadata = tokio::fs::metadata(&path).await.ok()?;
if metadata.len() > ARTIST_ART_MANIFEST_MAX_BYTES {
let _ = tokio::fs::remove_file(path).await;
return None;
}
// Age is taken here but judged below: how long this manifest counts as
// fresh depends on which provider tier produced it, which is only knowable
// once it has been parsed.
let age = metadata
.modified()
.ok()
.and_then(|modified| modified.elapsed().ok());
let bytes = tokio::fs::read(&path).await.ok()?;
let Ok(manifest) = serde_json::from_slice::<CachedArtistArtManifest>(&bytes) else {
let _ = tokio::fs::remove_file(path).await;
return None;
};
let art = manifest.art;
let valid_urls = art
.banners
.iter()
.chain(art.logos.iter())
.all(|url| url.starts_with("https://"));
// Identity and shape must hold whatever the recipe was; a manifest for the
// wrong artist is never usable, however recent.
if manifest.artist_id != artist_id
|| art.artist.id != artist_id
|| art.banner_custom
|| art.banners.len() > 8
|| art.logos.len() > 8
|| !valid_urls
{
return None;
}
let ttl = if manifest_from_top_tier(&art) {
ARTIST_ART_MANIFEST_TTL
} else {
ARTIST_ART_FALLBACK_TTL
};
let fresh = age.is_some_and(|age| age < ttl);
let current = fresh && manifest.recipe == ARTIST_ART_MANIFEST_RECIPE;
Some(StoredArtistArt { art, current })
}
/// The strict read: only a manifest that is fresh and matches the current
/// recipe. Callers that must not show an out-of-recipe hero use this.
async fn load_artist_art_manifest(
app: &tauri::AppHandle,
artist_id: u64,
) -> Option<metadata::CatalogArtistArt> {
read_artist_art_manifest(app, artist_id)
.await
.and_then(|stored| stored.current.then_some(stored.art))
}
/// Persist a resolved manifest off the request path. Re-stamps whatever is
/// written with the current recipe, which is also how an older-recipe manifest
/// that survived as a floor gets migrated forward.
fn store_art_manifest_in_background(
app: &tauri::AppHandle,
artist_id: u64,
art: &metadata::CatalogArtistArt,
) {
let store_app = app.clone();
let store_art = art.clone();
tauri::async_runtime::spawn(async move {
let result = tokio::task::spawn_blocking(move || {
store_artist_art_manifest(&store_app, artist_id, &store_art)
})
.await;
if let Ok(Err(error)) = result {
log::warn!("could not cache artist artwork manifest: {error}");
}
});
}
fn store_artist_art_manifest(
app: &tauri::AppHandle,
artist_id: u64,
art: &metadata::CatalogArtistArt,
) -> Result<(), String> {
if art.banners.is_empty() && art.logos.is_empty() {
return Ok(());
}
let path = artist_art_manifest_path(app, artist_id)?;
static LAST_SWEEP: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static FILE_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
static SWEEP_IN_PROGRESS: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
if let Some(directory) = path.parent() {
maybe_sweep_cache_directory(
directory,
ARTIST_ART_MANIFEST_TTL,
ARTIST_ART_MANIFEST_CACHE_MAX_FILES,
&LAST_SWEEP,
&FILE_COUNT,
&SWEEP_IN_PROGRESS,
);
}
let replaces_existing = path.is_file();
let manifest = CachedArtistArtManifest {
recipe: ARTIST_ART_MANIFEST_RECIPE.to_string(),
artist_id,
art: art.clone(),
};
let bytes = serde_json::to_vec(&manifest).map_err(|error| error.to_string())?;
if bytes.len() as u64 > ARTIST_ART_MANIFEST_MAX_BYTES {
return Err("artist artwork manifest is unexpectedly large".into());
}
let temporary = path.with_extension(format!("{}.tmp", std::process::id()));
std::fs::write(&temporary, bytes).map_err(|error| error.to_string())?;
if let Err(first_error) = std::fs::rename(&temporary, &path) {
let _ = std::fs::remove_file(&path);
if let Err(retry_error) = std::fs::rename(&temporary, &path) {
let _ = std::fs::remove_file(&temporary);
return Err(format!(
"artist art manifest rename failed ({first_error}); replacement failed ({retry_error})"
));
}
}
if !replaces_existing {
FILE_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
Ok(())
}
#[derive(Serialize)]
pub struct ArtistHeroOverride {
pub data_url: String,
pub width: u32,
pub height: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct CatalogArtistHeroSeed {
pub banner: String,
pub banner_custom: bool,
}
#[derive(Serialize)]
pub struct CatalogHeroArtResult {
pub data_url: String,
pub cacheable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CatalogHeroCropPlan {
pub recipe: String,
pub source_checksum: String,
#[serde(flatten)]
pub geometry: HeroCropPlan,
}
#[derive(Serialize)]
pub struct CatalogHeroPreviewResult {
pub data_url: String,
pub plan: CatalogHeroCropPlan,
}
/// Header preceding the raw RGBA payload returned to the live GPU hero path.
/// The WebView receives this as an ArrayBuffer through Tauri's binary IPC,
/// avoiding both base64 expansion and a lossy JPEG round-trip:
///
/// magic[4] | width(u32 LE) | height(u32 LE) | grain_seed(u32 LE) | RGBA8
const CATALOG_HERO_GPU_MAGIC: &[u8; 4] = b"SHG1";
const CATALOG_HERO_GPU_HEADER_BYTES: usize = 16;
/// The CHOSEN hero per artist — a decision about this library, not a render
/// cache — so it travels with the library. (The upscaled results keyed by URL
/// and recipe stay in the machine's cache dir: they regenerate cheaply and
/// would otherwise put gigabytes of derived pixels on the drive.)
fn artist_hero_dir(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
let cfg = config::load(app);
let dir = config::ensure_library_data_dir(app, &cfg)
.map_err(|e| e.to_string())?
.join("artist-heroes");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir)
}
fn finished_hero_cache_path(
app: &tauri::AppHandle,
url: &str,
upscaler: config::ArtistHeroUpscaler,
) -> Result<std::path::PathBuf, String> {
use std::hash::{Hash as _, Hasher as _};
let dir = app
.path()
.app_cache_dir()
.map_err(|error| error.to_string())?
.join("finished-artist-heroes");
std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
let mut key = std::collections::hash_map::DefaultHasher::new();
ARTIST_HERO_CACHE_RECIPE.hash(&mut key);
match upscaler {
config::ArtistHeroUpscaler::LiveGpu => "live_gpu",
config::ArtistHeroUpscaler::Fsr => "fsr",
config::ArtistHeroUpscaler::Lanczos3 => "lanczos3",
}
.hash(&mut key);
url.hash(&mut key);
Ok(dir.join(format!("{:016x}.data", key.finish())))
}
fn finished_hero_checksum(data_url: &str) -> u64 {
use std::hash::{Hash as _, Hasher as _};
let mut checksum = std::collections::hash_map::DefaultHasher::new();
data_url.as_bytes().hash(&mut checksum);
checksum.finish()
}
fn validate_finished_hero(mut cached: String) -> Option<String> {
let footer = cached.rfind('\n')?;
let expected = u64::from_str_radix(cached.get(footer + 1..)?, 16).ok()?;
cached.truncate(footer);
(cached.len() > 1_000
&& cached.starts_with("data:image/")
&& cached.contains(";base64,")
&& finished_hero_checksum(&cached) == expected)
.then_some(cached)
}
fn store_finished_hero(path: &std::path::Path, data_url: &str) -> Result<(), String> {
use std::io::Write as _;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_WRITE: AtomicU64 = AtomicU64::new(1);
let id = NEXT_WRITE.fetch_add(1, Ordering::Relaxed);
let temporary = path.with_extension(format!("{}.{}.tmp", std::process::id(), id));
let mut file = std::fs::File::create(&temporary).map_err(|error| error.to_string())?;
file.write_all(data_url.as_bytes())
.map_err(|error| error.to_string())?;
write!(file, "\n{:016x}", finished_hero_checksum(data_url))
.map_err(|error| error.to_string())?;
file.sync_all().map_err(|error| error.to_string())?;
drop(file);
if let Err(first_error) = std::fs::rename(&temporary, path) {
let existing_is_valid = std::fs::read_to_string(path)
.ok()
.and_then(validate_finished_hero)
.is_some();
if existing_is_valid {
let _ = std::fs::remove_file(&temporary);
} else {
let _ = std::fs::remove_file(path);
if let Err(retry_error) = std::fs::rename(&temporary, path) {
let _ = std::fs::remove_file(&temporary);
return Err(format!(
"cache rename failed ({first_error}); replacement failed ({retry_error})"
));
}
}
}
// Bound the persistent cache by evicting oldest outputs. This runs only
// after an expensive new render, never on the latency-sensitive read path.
let Some(dir) = path.parent() else {
return Ok(());
};
let mut files = std::fs::read_dir(dir)
.map_err(|error| error.to_string())?
.filter_map(Result::ok)
.filter_map(|entry| {
let path = entry.path();
if path.extension().and_then(|extension| extension.to_str()) != Some("data") {
return None;
}
let metadata = entry.metadata().ok()?;
metadata.is_file().then_some((
path,
metadata.len(),
metadata.modified().unwrap_or(std::time::UNIX_EPOCH),
))
})
.collect::<Vec<_>>();
let mut total = files.iter().map(|(_, bytes, _)| *bytes).sum::<u64>();
files.sort_by_key(|(_, _, modified)| *modified);
for (oldest, bytes, _) in files {
if total <= ARTIST_HERO_CACHE_MAX_BYTES {
break;
}
if std::fs::remove_file(oldest).is_ok() {
total = total.saturating_sub(bytes);
}
}
Ok(())
}
async fn load_finished_hero(path: &std::path::Path) -> Option<String> {
let metadata = tokio::fs::metadata(path).await.ok()?;
let fresh = metadata
.modified()
.ok()
.and_then(|modified| modified.elapsed().ok())
.is_some_and(|age| age < ARTIST_HERO_CACHE_TTL);
if fresh && metadata.len() <= ARTIST_HERO_CACHE_MAX_ENTRY_BYTES {
if let Ok(cached) = tokio::fs::read_to_string(path).await {
if let Some(cached) = validate_finished_hero(cached) {
return Some(cached);
}
}
}
let _ = tokio::fs::remove_file(path).await;
None
}
fn artist_image_format(path: &std::path::Path) -> Result<(&'static str, &'static str), String> {
let format = image::ImageReader::open(path)
.map_err(|e| e.to_string())?
.with_guessed_format()
.map_err(|e| e.to_string())?
.format()
.ok_or_else(|| "Could not identify that image format".to_string())?;
match format {
image::ImageFormat::Jpeg => Ok(("jpg", "image/jpeg")),
image::ImageFormat::Png => Ok(("png", "image/png")),
image::ImageFormat::WebP => Ok(("webp", "image/webp")),
_ => Err("Choose a JPEG, PNG, or WebP image".into()),
}
}
fn validate_artist_hero(path: &std::path::Path) -> Result<(u32, u32), String> {
let size = std::fs::metadata(path).map_err(|e| e.to_string())?.len();
if size > ARTIST_HERO_MAX_BYTES {
return Err("Hero images must be 30 MB or smaller".into());
}
let (width, height) = image::image_dimensions(path).map_err(|e| e.to_string())?;
if width < ARTIST_HERO_MIN_WIDTH || height < ARTIST_HERO_MIN_HEIGHT {
return Err(format!(
"That image is {width}×{height}. Choose at least {ARTIST_HERO_MIN_WIDTH}×{ARTIST_HERO_MIN_HEIGHT}."
));
}
if u64::from(width) * 10 < u64::from(height) * 13 {
return Err(format!(
"That image is {width}×{height}. Choose a landscape photo for the wide hero."
));
}
Ok((width, height))
}
fn load_artist_hero_override(
app: &tauri::AppHandle,
artist_id: u64,
) -> Result<Option<ArtistHeroOverride>, String> {
use base64::Engine as _;
let dir = artist_hero_dir(app)?;
for (ext, mime) in [
("jpg", "image/jpeg"),
("png", "image/png"),
("webp", "image/webp"),
] {
let path = dir.join(format!("{artist_id}.{ext}"));
if !path.is_file() {
continue;
}
let (width, height) = validate_artist_hero(&path)?;
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
return Ok(Some(ArtistHeroOverride {
data_url: format!(
"data:{mime};base64,{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
),
width,
height,
}));
}
Ok(None)
}
/// Import a user-owned high-resolution artist hero into app data. The numeric
/// catalog id makes the stored filename collision-safe without trusting an
/// artist name as a filesystem path.
#[tauri::command]
pub async fn pick_artist_hero(
app: tauri::AppHandle,
artist_id: u64,
) -> Result<Option<ArtistHeroOverride>, String> {
use tauri_plugin_dialog::DialogExt;
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog()
.file()
.add_filter("High-resolution image", &["jpg", "jpeg", "png", "webp"])
.pick_file(move |path| {
let _ = tx.send(path.map(|p| p.to_string()));
});
let Some(source) = rx.await.unwrap_or(None) else {
return Ok(None);
};
let source = std::path::PathBuf::from(source);
let (width, height) = validate_artist_hero(&source)?;
let (ext, _) = artist_image_format(&source)?;
let dir = artist_hero_dir(&app)?;
let temporary = dir.join(format!("{artist_id}.importing"));
std::fs::copy(&source, &temporary).map_err(|e| e.to_string())?;
for old_ext in ["jpg", "png", "webp"] {
let _ = std::fs::remove_file(dir.join(format!("{artist_id}.{old_ext}")));
}
let destination = dir.join(format!("{artist_id}.{ext}"));
if let Err(error) = std::fs::rename(&temporary, &destination) {
let _ = std::fs::remove_file(&temporary);
return Err(error.to_string());
}
let mut hero = load_artist_hero_override(&app, artist_id)?
.ok_or_else(|| "The imported hero could not be reopened".to_string())?;
hero.width = width;
hero.height = height;
Ok(Some(hero))
}
#[tauri::command]
pub fn clear_artist_hero(app: tauri::AppHandle, artist_id: u64) -> Result<bool, String> {
let dir = artist_hero_dir(&app)?;
let mut removed = false;
for ext in ["jpg", "png", "webp"] {
let path = dir.join(format!("{artist_id}.{ext}"));
if path.is_file() {
std::fs::remove_file(path).map_err(|e| e.to_string())?;
removed = true;
}
}
Ok(removed)
}
#[tauri::command]
pub async fn catalog_search(
state: State<'_, LibraryState>,
query: String,
) -> Result<metadata::CatalogSearch, String> {
state.touch_catalog_activity();
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>,
isrc: String,
) -> Result<Option<metadata::CatalogTrack>, String> {
state.touch_catalog_activity();
Ok(state.metadata.catalog_track_by_isrc(&isrc).await)
}
#[tauri::command]
pub async fn catalog_artist(
app: tauri::AppHandle,
state: State<'_, LibraryState>,
artist_id: u64,
) -> Result<metadata::CatalogArtistPage, String> {
state.touch_catalog_activity();
let mut page = state
.metadata
.catalog_artist(artist_id)
.await
.ok_or_else(|| "artist lookup failed".to_string())?;
if let Some(custom) = load_artist_hero_override(&app, artist_id)? {
page.banner = Some(custom.data_url.clone());
// A deliberate user override remains fixed instead of being diluted by
// automatic provider art. Clearing it restores the provider carousel.
page.banners = vec![custom.data_url];
page.banner_custom = true;
}
Ok(page)
}
#[tauri::command]
pub async fn catalog_artist_art(
app: tauri::AppHandle,
state: State<'_, LibraryState>,
artist_id: u64,
) -> Result<metadata::CatalogArtistArt, String> {
// The selected provider/Bing URL tier is stable for the same period as the
// metadata cache. Persisting that tiny manifest means an app restart can
// address the already-known hero immediately instead of rediscovering Bing.
let stored = read_artist_art_manifest(&app, artist_id).await;
let mut art = match stored {
// Fresh, current recipe, and the host is still serving the images:
// serve it, no network. The liveness check matters because a manifest
// outlives the CDN behind it -- measured 2026-07-28, Fanart.tv's API
// stayed up while `assets.fanart.tv` refused every connection, so every
// stored manifest naming it replayed a hero that could never load and
// short-circuited the discovery that would have found a working one.
// One probe per host per 5 minutes, so the hero returns on its own once
// the CDN does.
Some(stored)
if stored.current && state.metadata.art_is_servable(&stored.art).await =>
{
stored.art
}
// Either nothing on disk, or something stale/older-recipe. Try to do
// better, but keep what we have as a floor: the discovery tier fails
// per-artist-name (Bing collapses rare names onto popular entities and
// intermittently serves other queries' results), so a failed lookup is
// a normal event, not a reason to show an artist with no hero.
other => {
let resolved = state.metadata.catalog_artist_art(artist_id).await;
// Only banners we already had AND can still show count as a floor.
// Restoring unreachable ones would undo the whole point of falling
// through: discovery correctly dropped a dead CDN, and the floor
// would put it straight back. Note that `catalog_artist_art`
// returns Some as soon as the Deezer *profile* resolves, so "found
// nothing" arrives as Some with an empty banner list -- an
// emptiness check, not a None check, is what detects a failed hero
// discovery.
let previous = match other.map(|stored| stored.art) {
Some(art)
if !art.banners.is_empty()
&& state.metadata.art_is_servable(&art).await =>
{
Some(art)
}
_ => None,
};
match (resolved, previous) {
(Some(mut resolved), Some(previous)) if resolved.banners.is_empty() => {
log::info!(
"artist {artist_id}: hero discovery found nothing, keeping {} \
previously resolved banner(s)",
previous.banners.len()
);
resolved.banner = previous.banner;
resolved.banners = previous.banners;
if resolved.logos.is_empty() {
resolved.logos = previous.logos;
}
store_art_manifest_in_background(&app, artist_id, &resolved);
resolved
}
(Some(resolved), _) => {
store_art_manifest_in_background(&app, artist_id, &resolved);
resolved
}
(None, Some(previous)) => previous,
(None, None) => return Err("artist artwork lookup failed".to_string()),
}
}
};
if let Some(custom) = load_artist_hero_override(&app, artist_id)? {
art.banner = Some(custom.data_url.clone());
art.banners = vec![custom.data_url];
art.banner_custom = true;
}
Ok(art)
}
/// Definitive provider-logo candidates without waiting for the independent
/// Bing hero fallback. The frontend uses this to decide whether cover OCR is
/// allowed as soon as the exact release list is ready.
#[tauri::command]
pub async fn catalog_artist_logos(
state: State<'_, LibraryState>,
artist_id: u64,
) -> Result<Vec<String>, String> {
state
.metadata
.catalog_artist_logos(artist_id)
.await
.ok_or_else(|| "artist logo lookup failed".to_string())
}
/// Deferred MusicBrainz pass — MB-only releases + type hints. Kept out of
/// `catalog_artist` so its rate-limited (1 req/s) calls never delay the grid.
/// Socials ride `catalog_artist_socials` below, off this critical path.
#[tauri::command]
pub async fn catalog_artist_identity(
state: State<'_, LibraryState>,
artist_id: u64,
artist: String,
) -> Result<metadata::CatalogArtistIdentity, String> {
Ok(state
.metadata
.catalog_artist_identity(artist_id, &artist)
.await)
}
/// Identity-anchored artist biography for the Now Playing panel. Replaces a
/// frontend "first Wikipedia hit for `<name> musician band`" lookup that
/// regularly described a different artist entirely, and which also bypassed
/// this client's Wikimedia UA/pacing/caching.
#[tauri::command]
pub async fn catalog_artist_bio(
state: State<'_, LibraryState>,
artist_id: Option<u64>,
artist: String,
) -> Result<Option<String>, String> {
state.touch_catalog_activity();
Ok(state.metadata.artist_bio(artist_id, &artist).await)
}
/// Persist "this Deezer artist record holds more than one act, show this
/// portrait instead".
///
/// The verdict costs rate-limited MusicBrainz and Apple lookups, so the search
/// bar — typed against per keystroke — can only ever consult a remembered one.
/// The frontend calls this once the identity merge has proven the record fused
/// AND the identity-gated hero has resolved, so every later surface (suggestion
/// dropdown, search results, related artists, Now Playing) agrees for free.
#[tauri::command]
pub async fn remember_fused_artist_portrait(
state: State<'_, LibraryState>,
artist_id: u64,
portrait: Option<String>,
) -> Result<(), String> {
state
.metadata
.remember_fused_portrait(artist_id, portrait);
Ok(())
}
/// Footer links, fetched only after the sorting-critical identity merge has
/// landed — one MusicBrainz request that used to cost every cold artist ~1 s
/// of time-to-correctly-sorted inside the identity join.
#[tauri::command]
pub async fn catalog_artist_socials(
state: State<'_, LibraryState>,
artist_id: u64,
) -> Result<Vec<metadata::ArtistLink>, String> {
Ok(state.metadata.catalog_artist_socials(artist_id).await)
}
// v4: the alpha ramp now scales to the measured ink distance, so every cached
// mask predates the anti-aliasing this pipeline relies on and must be redrawn.
// v5: three changes, each of which can turn a previously-empty result into a
// mark (or trim one that over-reached), so every v4 entry is stale by
// construction — the alpha ramp's ceiling can no longer exceed the ink's own
// distance, the dominance guard admits flat multi-tone crops, and a row walk
// that never met a gap falls back to the OCR box instead of running to the crop
// edge. Wand had four covers printing its name and v4 extracted none of them.
// v10: the winner's supersample scale is fractional instead of a floored
// integer. Every mask whose crop was wider than half the target was previously
// supersampled by exactly 1x — i.e. not at all — so the covers this rewrites are
// the LARGE printed wordmarks, which is where the staircase was worst.
const COVER_LOGO_RECIPE: &str = "cover-logo-v10-supersample-2400-fractional";
// A prolific artist's wordmark is often nowhere near the top of this list. The
// rank puts albums first and penalises `remix|mix|edit|live` titles — but for
// Poolside (74 releases) every one of the SEVEN covers that OCR reads as
// "POOLSIDE" is a remix single, so all seven sat outside a 14-cover window and
// the artist silently lost its logo. Raising the cap costs nothing for artists
// that hit early: the locate loop stops as soon as COVER_LOGO_MAX_HITS is
// reached, so the extra covers are only ever fetched when the early ones miss.
// The price is paid by artists with no wordmark anywhere, who now OCR more
// covers before giving up — once, then it is cached.
const COVER_LOGO_MAX_COVERS: usize = 40;
const COVER_LOGO_MAX_HITS: usize = 5;
const COVER_LOGO_LOCATE_WINDOW: usize = 4;
const COVER_LOGO_UPGRADE_WINDOW: usize = 4;
/// Completed cover-derived logos, including session-scoped negative results.
/// The full input fingerprint prevents namesakes or a changed ranked cover list
/// from sharing a result.
fn cover_logo_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Option<String>>>
{
static CACHE: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, Option<String>>>,
> = std::sync::OnceLock::new();
CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
}
type CoverLogoResult = Result<Option<String>, String>;
/// Rapid navigation can ask for the same artist again before the first OCR pass
/// finishes. Share that work instead of launching a second full pipeline.
fn cover_logo_inflight() -> &'static tokio::sync::Mutex<
std::collections::HashMap<String, std::sync::Arc<tokio::sync::OnceCell<CoverLogoResult>>>,
> {
static INFLIGHT: std::sync::OnceLock<
tokio::sync::Mutex<
std::collections::HashMap<
String,
std::sync::Arc<tokio::sync::OnceCell<CoverLogoResult>>,
>,
>,
> = std::sync::OnceLock::new();
INFLIGHT.get_or_init(|| tokio::sync::Mutex::new(std::collections::HashMap::new()))
}
/// One album cover the frontend offers as a wordmark source: its title (so the
/// backend can resolve the same art at a higher resolution) and its cover URL.
#[derive(Debug, Clone, Deserialize)]
pub struct CoverRef {
pub title: String,
pub url: String,
}
fn cover_logo_key(artist_id: u64, artist: &str, covers: &[CoverRef]) -> String {
use std::hash::{Hash as _, Hasher as _};
let mut hash = std::collections::hash_map::DefaultHasher::new();
COVER_LOGO_RECIPE.hash(&mut hash);
artist_id.hash(&mut hash);
artist.to_lowercase().hash(&mut hash);
for cover in covers.iter().take(COVER_LOGO_MAX_COVERS) {
cover.title.hash(&mut hash);
cover.url.hash(&mut hash);
}
format!("{artist_id}-{:016x}", hash.finish())
}
fn cover_logo_disk_dir(app: &tauri::AppHandle) -> Option<std::path::PathBuf> {
// `catalog_cached_cover_logo` restores the newest positive file by artist
// id before the current cover fingerprint is available. Isolate this
// identity-hardened generation so a pre-hardening logo derived from a
// fused namesake release can never flash during that early restore.
let dir = app.path().app_cache_dir().ok()?.join("cover-logos-v3");
std::fs::create_dir_all(&dir).ok()?;
Some(dir)
}
fn cover_logo_disk_path(app: &tauri::AppHandle, key: &str) -> Option<std::path::PathBuf> {
Some(cover_logo_disk_dir(app)?.join(format!("{key}.data")))
}
fn valid_cached_cover_logo(value: &str) -> bool {
value.starts_with("data:image/png;base64,") && value.len() > 64
}
async fn read_cover_logo_disk(path: &std::path::Path) -> Option<String> {
let value = tokio::fs::read_to_string(path).await.ok()?;
valid_cached_cover_logo(&value).then_some(value)
}
async fn write_cover_logo_disk(path: &std::path::Path, value: &str) {
let temp = path.with_extension(format!("tmp-{}", std::process::id()));
if tokio::fs::write(&temp, value).await.is_err() {
return;
}
// Windows rename does not replace an existing destination. Invalid or
// interrupted cache entries are disposable and regenerated here.
let _ = tokio::fs::remove_file(path).await;
if tokio::fs::rename(&temp, path).await.is_err() {
let _ = tokio::fs::remove_file(&temp).await;
}
}
struct LocatedCoverLogo {
rank: usize,
title: String,
url: String,
bytes_1000: std::sync::Arc<Vec<u8>>,
loc: crate::cover_logo::BandLocation,
dims_1000: (u32, u32),
}
struct DownloadedCoverLogo {
rank: usize,
title: String,
url: String,
bytes_1000: std::sync::Arc<Vec<u8>>,
}
struct CoverLogoDownloadJob {
download_elapsed: std::time::Duration,
downloaded: Option<DownloadedCoverLogo>,
}
async fn download_cover_logo_candidate(
metadata: std::sync::Arc<metadata::MetadataClient>,
rank: usize,
cover: CoverRef,
) -> CoverLogoDownloadJob {
let download_started = std::time::Instant::now();
let Some(bytes_1000) = metadata.cover_bytes(&cover.url).await else {
return CoverLogoDownloadJob {
download_elapsed: download_started.elapsed(),
downloaded: None,
};
};
CoverLogoDownloadJob {
download_elapsed: download_started.elapsed(),
downloaded: Some(DownloadedCoverLogo {
rank,
title: cover.title,
url: cover.url,
bytes_1000,
}),
}
}
async fn locate_downloaded_cover_logo(
downloaded: DownloadedCoverLogo,
artist: std::sync::Arc<str>,
) -> (std::time::Duration, Option<LocatedCoverLogo>) {
let locate_started = std::time::Instant::now();
let bytes_for_ocr = std::sync::Arc::clone(&downloaded.bytes_1000);
let found = tokio::task::spawn_blocking(move || {
use image::GenericImageView as _;
let image = image::load_from_memory(bytes_for_ocr.as_slice()).ok()?;
let dims = image.dimensions();
let loc = crate::cover_logo::locate_wordmark(&image, &artist)
.ok()
.flatten()?;
Some((loc, dims))
})
.await
.ok()
.flatten();
let locate_elapsed = locate_started.elapsed();
(
locate_elapsed,
found.map(|(loc, dims_1000)| LocatedCoverLogo {
rank: downloaded.rank,
title: downloaded.title,
url: downloaded.url,
bytes_1000: downloaded.bytes_1000,
loc,
dims_1000,
}),
)
}
struct CoverLogoCandidate {
rank: usize,
title: String,
bytes_1000: std::sync::Arc<Vec<u8>>,
loc: crate::cover_logo::BandLocation,
dims_1000: (u32, u32),
bytes_hi: Option<std::sync::Arc<Vec<u8>>>,
}
struct CoverLogoUpgradeJob {
elapsed: std::time::Duration,
candidate: CoverLogoCandidate,
}
async fn upgrade_cover_logo_candidate(
metadata: std::sync::Arc<metadata::MetadataClient>,
artist_id: u64,
artist: std::sync::Arc<str>,
located: LocatedCoverLogo,
) -> CoverLogoUpgradeJob {
let started = std::time::Instant::now();
let hi_url = metadata
.resolve_cover_for_artist_id(Some(artist_id), &artist, &located.title, &located.url)
.await
.unwrap_or_else(|| located.url.clone());
let bytes_hi = if hi_url != located.url {
metadata.cover_bytes(&hi_url).await
} else {
None
};
CoverLogoUpgradeJob {
elapsed: started.elapsed(),
candidate: CoverLogoCandidate {
rank: located.rank,
title: located.title,
bytes_1000: located.bytes_1000,
loc: located.loc,
dims_1000: located.dims_1000,
bytes_hi,
},
}
}
async fn generate_cover_logo(
metadata: std::sync::Arc<metadata::MetadataClient>,
artist_id: u64,
artist: String,
covers: Vec<CoverRef>,
) -> CoverLogoResult {
let total_started = std::time::Instant::now();
let offered: Vec<_> = covers.into_iter().take(COVER_LOGO_MAX_COVERS).collect();
let artist_shared: std::sync::Arc<str> = std::sync::Arc::from(artist.as_str());
// Download and OCR a rank-ordered sliding window. Tasks run concurrently,
// but awaiting the handles from the front commits results in original rank
// order. Stopping at hit five therefore selects exactly the old first-five
// hit set; at most WINDOW-1 later covers have speculative work in flight.
let locate_pipeline_started = std::time::Instant::now();
let mut locate_pending = std::collections::VecDeque::new();
let mut next_rank = 0usize;
while next_rank < offered.len() && locate_pending.len() < COVER_LOGO_LOCATE_WINDOW {
let metadata = std::sync::Arc::clone(&metadata);
let cover = offered[next_rank].clone();
let rank = next_rank;
locate_pending.push_back((
rank,
tokio::spawn(download_cover_logo_candidate(metadata, rank, cover)),
));
next_rank += 1;
}
let upgrade_slots = std::sync::Arc::new(tokio::sync::Semaphore::new(COVER_LOGO_UPGRADE_WINDOW));
let mut upgrade_pending = Vec::new();
let mut upgrade_started: Option<std::time::Instant> = None;
let mut hit_summary = Vec::new();
let mut located_count = 0usize;
let mut downloaded_count = 0usize;
let mut download_work = std::time::Duration::ZERO;
let mut locate_work = std::time::Duration::ZERO;
while let Some((rank, handle)) = locate_pending.pop_front() {
match handle.await {
Ok(job) => {
download_work += job.download_elapsed;
if let Some(downloaded) = job.downloaded {
downloaded_count += 1;
// OCR itself stays rank-ordered and sequential. Windows OCR
// produced occasional different boxes under parallel engine
// pressure; overlapping downloads retains most of the wall
// win while preserving the original deterministic pass order.
let (elapsed, hit) = locate_downloaded_cover_logo(
downloaded,
std::sync::Arc::clone(&artist_shared),
)
.await;
locate_work += elapsed;
if let Some(hit) = hit {
located_count += 1;
hit_summary.push((hit.rank, hit.title.clone()));
upgrade_started.get_or_insert_with(std::time::Instant::now);
let rank = hit.rank;
let metadata = std::sync::Arc::clone(&metadata);
let artist = std::sync::Arc::clone(&artist_shared);
let slots = std::sync::Arc::clone(&upgrade_slots);
upgrade_pending.push((
rank,
tokio::spawn(async move {
let _permit = slots.acquire_owned().await.ok();
upgrade_cover_logo_candidate(metadata, artist_id, artist, hit).await
}),
));
}
}
}
Err(error) => log::warn!("cover-logo locate task rank {rank} failed: {error}"),
}
if located_count >= COVER_LOGO_MAX_HITS {
for (_, handle) in locate_pending.drain(..) {
handle.abort();
}
break;
}
if next_rank < offered.len() {
let metadata = std::sync::Arc::clone(&metadata);
let cover = offered[next_rank].clone();
let rank = next_rank;
locate_pending.push_back((
rank,
tokio::spawn(download_cover_logo_candidate(metadata, rank, cover)),
));
next_rank += 1;
}
}
let locate_pipeline_elapsed = locate_pipeline_started.elapsed();
// Hi-res resolution begins as soon as each ordered OCR hit commits, so its
// network time overlaps the remaining OCR passes. Handles are consumed in
// hit/rank order to retain exact equal-score winner semantics.
let mut candidates = Vec::with_capacity(located_count);
let mut upgrade_work = std::time::Duration::ZERO;
for (rank, handle) in upgrade_pending {
match handle.await {
Ok(job) => {
upgrade_work += job.elapsed;
candidates.push(job.candidate);
}
Err(error) => log::warn!("cover-logo upgrade task rank {rank} failed: {error}"),
}
}
let upgrade_elapsed = upgrade_started
.map(|started| started.elapsed())
.unwrap_or(std::time::Duration::ZERO);
// Selection recipe is intentionally unchanged. Keep the best image in
// memory and PNG-encode once, rather than encoding every provisional best.
const PROMINENCE_W: f32 = 3.0;
const MIN_PROMINENCE: f32 = 0.05;
const MIN_MARK_H: u32 = 74;
// Mask long edge the WINNER is re-cut at. Matching the tracer's own raster
// is the whole rationale: one good Lanczos enlargement in Rust instead of
// Lanczos followed by the browser's bilinear, with the trace then running at
// roughly the mask's native resolution.
//
// Swept against 1200/3600/4800 over eight artists (see
// `research/cover-logo-superres/prototypes/supersample-batch.mjs`), and 2400
// is where the curve flattens. 1200 is below several crops and leaves them
// untouched. Past 2400 the mask overshoots the trace raster, so the browser
// DOWNSCALES what Rust just built — every shape measure is flat from there
// (Poolside, Wand, Camping In Alaska, Turnover all identical at 2400 and
// 3600) while the data URL doubles: Mildlife 137KB -> 267KB, Camping 76KB ->
// 152KB, for nothing. The one artist that still gains above 2400 is Hot
// Toddy, and it gains from the downscale itself rather than from the extra
// detail — the same smoothing the tracer's blur term would give it.
const MASK_SUPERSAMPLE_EDGE: u32 = 2400;
let extract_started = std::time::Instant::now();
let (best, winner) = tokio::task::spawn_blocking(move || {
use base64::Engine as _;
use image::{GenericImageView as _, ImageEncoder as _};
// The winner's INPUTS, not its mask: the mask is re-cut once below at
// full supersample, so scoring never pays for it and never sees it.
let mut best: Option<(f32, CoverLogoCandidate)> = None;
for candidate in candidates {
let prom_w = (candidate.loc.rect.w / candidate.dims_1000.0 as f32).min(1.0);
if prom_w < MIN_PROMINENCE {
continue;
}
let mark = candidate
.bytes_hi
.as_ref()
.and_then(|bytes| image::load_from_memory(bytes.as_slice()).ok())
.and_then(|hi| {
let (hw, hh) = hi.dimensions();
let scaled = candidate.loc.scaled(
hw as f32 / candidate.dims_1000.0 as f32,
hh as f32 / candidate.dims_1000.1 as f32,
);
crate::cover_logo::extract_wordmark(&hi, &scaled, 0)
})
.or_else(|| {
image::load_from_memory(candidate.bytes_1000.as_slice())
.ok()
.and_then(|image| {
crate::cover_logo::extract_wordmark(&image, &candidate.loc, 0)
})
});
let Some(mark) = mark else { continue };
if mark.image.height() < MIN_MARK_H {
continue;
}
let final_score = mark.score + PROMINENCE_W * prom_w;
if best.as_ref().is_none_or(|(score, _)| final_score > *score) {
best = Some((final_score, candidate));
}
}
let Some((_, winner)) = best else {
return (None, None);
};
let (rank, title) = (winner.rank, winner.title.clone());
// Re-cut the winning mark, this time resampling the crop up before the
// alpha ramp so the ramp reads interpolated colour and places the edge
// between source pixels rather than on them. Selection above ran at
// native scale on purpose — see `try_extract_wordmark`'s `target_edge`.
// Falls back to the selection-scale mask, so the richer pass failing can
// never cost an artist its logo.
let recut = winner
.bytes_hi
.as_ref()
.and_then(|bytes| image::load_from_memory(bytes.as_slice()).ok())
.and_then(|hi| {
let (hw, hh) = hi.dimensions();
let scaled = winner.loc.scaled(
hw as f32 / winner.dims_1000.0 as f32,
hh as f32 / winner.dims_1000.1 as f32,
);
crate::cover_logo::extract_wordmark(&hi, &scaled, MASK_SUPERSAMPLE_EDGE)
.or_else(|| crate::cover_logo::extract_wordmark(&hi, &scaled, 0))
})
.or_else(|| {
image::load_from_memory(winner.bytes_1000.as_slice())
.ok()
.and_then(|img| {
crate::cover_logo::extract_wordmark(
&img,
&winner.loc,
MASK_SUPERSAMPLE_EDGE,
)
.or_else(|| crate::cover_logo::extract_wordmark(&img, &winner.loc, 0))
})
});
let Some(image) = recut.map(|mark| mark.image) else {
return (None, None);
};
let (width, height) = image.dimensions();
let mut png = Vec::new();
if image::codecs::png::PngEncoder::new(&mut png)
.write_image(
image.as_raw(),
width,
height,
image::ExtendedColorType::Rgba8,
)
.is_err()
{
return (None, None);
}
(
Some(format!(
"data:image/png;base64,{}",
base64::engine::general_purpose::STANDARD.encode(&png)
)),
Some((rank, title, width, height)),
)
})
.await
.map_err(|error| error.to_string())?;
let extract_elapsed = extract_started.elapsed();
log::info!(
"cover-logo perf artist={artist:?} offered={} downloaded={downloaded_count} hits={located_count} locate_pipeline_ms={} download_work_ms={} ocr_work_ms={} upgrade_wall_ms={} upgrade_work_ms={} extract_ms={} total_ms={} hit_ranks={hit_summary:?} winner={winner:?}",
offered.len(),
locate_pipeline_elapsed.as_millis(),
download_work.as_millis(),
locate_work.as_millis(),
upgrade_elapsed.as_millis(),
upgrade_work.as_millis(),
extract_elapsed.as_millis(),
total_started.elapsed().as_millis(),
);
Ok(best)
}
/// Restore the most recently finalized cover-derived logo for an artist before
/// the exhaustive release list is ready. Only positive, recipe-versioned final
/// results are persisted, so this never exposes a speculative OCR candidate.
#[tauri::command]
pub async fn catalog_cached_cover_logo(
app: tauri::AppHandle,
artist_id: u64,
) -> Result<Option<String>, String> {
let Some(dir) = cover_logo_disk_dir(&app) else {
return Ok(None);
};
let prefix = format!("{artist_id}-");
let mut paths: Vec<_> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.filter_map(Result::ok)
.filter(|entry| {
entry.file_name().to_string_lossy().starts_with(&prefix)
&& entry.path().extension().and_then(|ext| ext.to_str()) == Some("data")
})
.collect();
paths.sort_by_key(|entry| {
std::cmp::Reverse(
entry
.metadata()
.and_then(|metadata| metadata.modified())
.ok(),
)
});
for entry in paths {
if let Some(value) = read_cover_logo_disk(&entry.path()).await {
return Ok(Some(value));
}
}
Ok(None)
}
/// Deferred: derive an artist wordmark from album covers for artists with no
/// provider (Fanart/TheAudioDB) logo. Downloads the covers, OCRs each for the
/// band-name line, segments the cleanest off its background onto transparency,
/// and returns it as a PNG data URL — `None` when no cover yields a clean
/// wordmark (those stay on the plain text-name fallback). Cached per artist.
///
/// Covers arrive already ranked studio-albums-first (their art prints the bare
/// band name; singles/remixes print tracklists and credits), so OCR walks the
/// clean covers first and early-outs once it has enough hits.
#[tauri::command]
pub async fn catalog_cover_logo(
app: tauri::AppHandle,
state: State<'_, LibraryState>,
artist_id: u64,
artist: String,
covers: Vec<CoverRef>,
) -> Result<Option<String>, String> {
let key = cover_logo_key(artist_id, &artist, &covers);
if let Some(hit) = cover_logo_cache().lock().unwrap().get(&key).cloned() {
log::info!("cover-logo cache memory artist={artist:?} artist_id={artist_id}");
return Ok(hit);
}
let disk_path = cover_logo_disk_path(&app, &key);
if let Some(path) = disk_path.as_deref() {
if let Some(hit) = read_cover_logo_disk(path).await {
cover_logo_cache()
.lock()
.unwrap()
.insert(key, Some(hit.clone()));
log::info!("cover-logo cache disk artist={artist:?} artist_id={artist_id}");
return Ok(Some(hit));
}
}
let inflight = {
let mut inflight = cover_logo_inflight().lock().await;
inflight
.entry(key.clone())
.or_insert_with(|| std::sync::Arc::new(tokio::sync::OnceCell::new()))
.clone()
};
let metadata = std::sync::Arc::clone(&state.metadata);
let generated = inflight
.get_or_init(|| generate_cover_logo(metadata, artist_id, artist.clone(), covers.clone()))
.await
.clone();
let should_persist = if let Ok(value) = &generated {
let mut cache = cover_logo_cache().lock().unwrap();
if cache.len() > 500 {
cache.clear();
}
let was_missing = !cache.contains_key(&key);
cache.insert(key.clone(), value.clone());
was_missing && value.is_some()
} else {
false
};
if should_persist {
if let (Some(path), Ok(Some(value))) = (disk_path.as_deref(), generated.as_ref()) {
write_cover_logo_disk(path, value).await;
}
}
let mut active = cover_logo_inflight().lock().await;
if active
.get(&key)
.is_some_and(|current| std::sync::Arc::ptr_eq(current, &inflight))
{
active.remove(&key);
}
generated
}
/// Per-cover diagnostic for the cover-logo pipeline: for each candidate cover,
/// what OCR line matched the artist name, its box (in the 1000px OCR space), the
/// hi-res source it resolved to, the extracted-mark size, and the extraction
/// score — plus which cover WON. Answers "which album art is the logo lifted
/// from, where on it, and how large?". Not cached; not on the hot path.
#[derive(Debug, Clone, Serialize)]
pub struct CoverLogoDiag {
pub title: String,
pub url: String,
pub cover_w: u32,
pub cover_h: u32,
pub matched: bool,
pub matched_text: String,
pub match_score: f32,
pub box_x: f32,
pub box_y: f32,
pub box_w: f32,
pub box_h: f32,
pub box_pct_w: f32,
pub box_pct_h: f32,
pub hi_url: String,
pub hi_w: u32,
pub hi_h: u32,
pub mark_w: u32,
pub mark_h: u32,
pub extract_score: f32,
pub reject_reason: String,
/// The extracted mask itself, as a `data:image/png` URL, or empty when the
/// cover produced none. The numbers above say a mark was found and how big
/// it is; only the pixels say whether its EDGES are worth tracing, which is
/// the whole question when sweeping `target_edge`. Empty on the OCR-matrix
/// paths that never extract.
pub mark_png: String,
pub winner: bool,
pub ocr: Vec<String>,
}
#[tauri::command]
pub async fn catalog_cover_logo_debug(
state: State<'_, LibraryState>,
artist_id: Option<u64>,
artist: String,
covers: Vec<CoverRef>,
target_edge: Option<u32>,
) -> Result<Vec<CoverLogoDiag>, String> {
use image::GenericImageView as _;
// Mask supersample target to segment at. 0/absent means native, which is
// what candidate SELECTION uses in production; a value here is for sweeping
// "does more supersampling help" without a rebuild per level.
let target_edge = target_edge.unwrap_or(0);
// Download 1000px covers for OCR.
let mut downloaded = Vec::new();
// Must match COVER_LOGO_MAX_COVERS, not undercut it. At 14 this diagnostic
// silently hid exactly the covers the real pipeline relies on — Poolside's
// seven POOLSIDE-readable covers are all remix singles ranked past 14, so
// the tool reported "one match" for an artist production finds several on.
for cover in covers.into_iter().take(COVER_LOGO_MAX_COVERS) {
if let Some(bytes) = state.metadata.cover_bytes(&cover.url).await {
downloaded.push((cover.title, cover.url, bytes));
}
}
// Locate the band-name line on each (blocking OCR).
let artist_locate = artist.clone();
let located = tokio::task::spawn_blocking(move || {
let mut out = Vec::new();
for (title, url, bytes) in downloaded {
let Ok(img) = image::load_from_memory(&bytes) else {
continue;
};
let (cw, ch) = img.dimensions();
let found = crate::cover_logo::locate_wordmark_named(&img, &artist_locate)
.ok()
.flatten();
let ocr = crate::cover_logo::ocr_lines_multi(&img)
.unwrap_or_default()
.into_iter()
.map(|l| {
format!(
"\"{}\" @{},{} {}x{}",
l.text,
l.rect.x.round(),
l.rect.y.round(),
l.rect.w.round(),
l.rect.h.round()
)
})
.collect();
out.push((title, url, bytes, cw, ch, found, ocr));
}
out
})
.await
.map_err(|e| e.to_string())?;
// For each hit: resolve to hi-res, extract, score.
let mut rows: Vec<CoverLogoDiag> = Vec::new();
for (title, url, bytes_1000, cw, ch, found, ocr) in located {
let mut row = CoverLogoDiag {
title: title.clone(),
url: url.clone(),
cover_w: cw,
cover_h: ch,
ocr,
matched: false,
matched_text: String::new(),
match_score: 0.0,
box_x: 0.0,
box_y: 0.0,
box_w: 0.0,
box_h: 0.0,
box_pct_w: 0.0,
box_pct_h: 0.0,
hi_url: url.clone(),
hi_w: cw,
hi_h: ch,
mark_w: 0,
mark_h: 0,
extract_score: 0.0,
reject_reason: String::new(),
mark_png: String::new(),
winner: false,
};
if let Some((loc, text, mscore)) = found {
row.matched = true;
row.matched_text = text;
row.match_score = mscore;
row.box_x = loc.rect.x;
row.box_y = loc.rect.y;
row.box_w = loc.rect.w;
row.box_h = loc.rect.h;
row.box_pct_w = loc.rect.w / cw as f32 * 100.0;
row.box_pct_h = loc.rect.h / ch as f32 * 100.0;
let hi_url = state
.metadata
.resolve_cover_for_artist_id(artist_id, &artist, &title, &url)
.await
.unwrap_or_else(|| url.clone());
let bytes_hi = if hi_url != url {
state.metadata.cover_bytes(&hi_url).await
} else {
None
};
let (bytes_ex, loc_ex, hw, hh) = match bytes_hi
.as_ref()
.and_then(|b| image::load_from_memory(b).ok())
{
Some(hi) => {
let (hw, hh) = hi.dimensions();
let scaled = loc.scaled(hw as f32 / cw as f32, hh as f32 / ch as f32);
(bytes_hi.clone().unwrap(), scaled, hw, hh)
}
None => (bytes_1000.clone(), loc, cw, ch),
};
row.hi_url = hi_url;
row.hi_w = hw;
row.hi_h = hh;
if let Ok(img_ex) = image::load_from_memory(&bytes_ex) {
match crate::cover_logo::try_extract_wordmark(&img_ex, &loc_ex, target_edge) {
Ok(mark) => {
use base64::Engine as _;
use image::ImageEncoder as _;
row.mark_w = mark.image.width();
row.mark_h = mark.image.height();
row.extract_score = mark.score;
let mut png = Vec::new();
if image::codecs::png::PngEncoder::new(&mut png)
.write_image(
mark.image.as_raw(),
row.mark_w,
row.mark_h,
image::ExtendedColorType::Rgba8,
)
.is_ok()
{
row.mark_png = format!(
"data:image/png;base64,{}",
base64::engine::general_purpose::STANDARD.encode(&png)
);
}
}
Err(reason) => row.reject_reason = reason.to_string(),
}
}
}
rows.push(row);
}
// Mark the winner using the SAME prominence-weighted score the real pipeline
// uses (extract score + 3.0 * box-width-fraction), so the diagnostic winner
// matches what the logo command would pick.
let final_score = |r: &CoverLogoDiag| r.extract_score + 3.0 * (r.box_pct_w / 100.0);
if let Some((idx, _)) = rows
.iter()
.enumerate()
.filter(|(_, r)| r.mark_h >= 74 && r.box_pct_w / 100.0 >= 0.05)
.max_by(|a, b| final_score(a.1).total_cmp(&final_score(b.1)))
{
rows[idx].winner = true;
}
Ok(rows)
}
/// EXPERIMENT: for each cover, run Windows OCR across a matrix of input scales
/// and preprocessing (raw vs Otsu-binarize) and report every line read. Answers
/// "does downscaling / flattening help the built-in OCR read stylized wordmarks
/// (Paper Years, Age of the Ego)?" before committing to a heavier local model.
#[tauri::command]
pub async fn catalog_ocr_experiment(
state: State<'_, LibraryState>,
urls: Vec<String>,
) -> Result<Vec<serde_json::Value>, String> {
let mut imgs = Vec::new();
for url in urls.into_iter().take(8) {
if let Some(bytes) = state.metadata.cover_bytes(&url).await {
imgs.push((url, bytes));
}
}
let out = tokio::task::spawn_blocking(move || {
use image::GenericImageView as _;
let scales = [1000u32, 768, 640, 512];
let mut results = Vec::new();
for (url, bytes) in imgs {
let Ok(base) = image::load_from_memory(&bytes) else {
continue;
};
let mut variants = serde_json::Map::new();
for &s in &scales {
let scaled = base.resize(s, s, image::imageops::FilterType::Lanczos3);
for (tag, img) in [
(format!("{s}px/raw"), scaled.clone()),
(format!("{s}px/bin"), crate::cover_logo::binarize(&scaled)),
] {
let lines = crate::cover_logo::ocr_lines(&img)
.unwrap_or_default()
.into_iter()
.map(|l| l.text)
.collect::<Vec<_>>();
variants.insert(tag, serde_json::json!(lines));
}
}
let (w, h) = base.dimensions();
results.push(
serde_json::json!({ "url": url, "base": format!("{w}x{h}"), "ocr": variants }),
);
}
results
})
.await
.map_err(|e| e.to_string())?;
Ok(out)
}
#[tauri::command]
pub async fn catalog_artist_hero_seed(
app: tauri::AppHandle,
state: State<'_, LibraryState>,
artist_id: u64,
) -> Result<Option<CatalogArtistHeroSeed>, String> {
// Local user art is the only truly instant high-confidence source. Check it
// before any remote identity/provider work; the complete art request still
// runs independently so automatic logos remain available.
if let Some(custom) = load_artist_hero_override(&app, artist_id)? {
return Ok(Some(CatalogArtistHeroSeed {
banner: custom.data_url,
banner_custom: true,
}));
}
if let Some(banner) = load_artist_art_manifest(&app, artist_id)
.await
.and_then(|art| art.banners.into_iter().next())
{
return Ok(Some(CatalogArtistHeroSeed {
banner,
banner_custom: false,
}));
}
Ok(state
.metadata
.catalog_artist_hero_seed(artist_id)
.await
.map(|banner| CatalogArtistHeroSeed {
banner,
banner_custom: false,
}))
}
#[tauri::command]
pub async fn catalog_artist_overview(
state: State<'_, LibraryState>,
artist_id: u64,
) -> Result<metadata::CatalogArtistOverview, String> {
state.touch_catalog_activity();
state
.metadata
.catalog_artist_overview(artist_id)
.await
.ok_or_else(|| "artist overview lookup failed".to_string())
}
#[tauri::command]
pub async fn catalog_artist_popular(
state: State<'_, LibraryState>,
artist_id: u64,
) -> Result<Vec<metadata::CatalogTrack>, String> {
state.touch_catalog_activity();
state
.metadata
.catalog_artist_popular(artist_id)
.await
.ok_or_else(|| "artist popular lookup failed".to_string())
}
/// Genres and Last.fm totals for the artist a search is about — the search
/// hero's stat line and tag chips. Deferred like every other supplement: it
/// rides MusicBrainz's gate and must never hold up the results.
#[tauri::command]
pub async fn catalog_artist_profile(
state: State<'_, LibraryState>,
artist_id: u64,
) -> Result<metadata::CatalogArtistProfile, String> {
state.touch_catalog_activity();
Ok(state.metadata.artist_profile(artist_id).await)
}
/// One search-result row, reduced to what a listener lookup needs.
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TrackListenerQuery {
/// The PERFORMING artist's Deezer id. `None` for a row the provider gave no
/// artist id for, which simply gets no count — never a guess from the name.
pub artist_id: Option<u64>,
pub title: String,
}
/// Last.fm listener counts for a set of search-result rows, answered in order.
///
/// Deferred on purpose: `catalog_search` is typed against per keystroke and must
/// stay three Deezer calls, so the counts are asked for separately once the
/// results are on screen. See `MetadataClient::track_listeners`.
#[tauri::command]
pub async fn catalog_track_listeners(
state: State<'_, LibraryState>,
tracks: Vec<TrackListenerQuery>,
) -> Result<Vec<Option<u64>>, String> {
state.touch_catalog_activity();
let rows: Vec<(Option<u64>, String)> = tracks
.into_iter()
.map(|t| (t.artist_id, t.title))
.collect();
Ok(state.metadata.track_listeners(&rows).await)
}
#[tauri::command]
pub async fn catalog_related(
state: State<'_, LibraryState>,
artist_id: u64,
) -> Result<Vec<metadata::CatalogArtist>, String> {
Ok(state.metadata.catalog_related(artist_id).await)
}
/// Proxy a catalog cover image into a data URL (CDNs lack CORS headers).
#[tauri::command]
pub async fn catalog_art(state: State<'_, LibraryState>, url: String) -> Result<String, String> {
if !url.starts_with("https://") {
return Err("not an https url".into());
}
state
.metadata
.fetch_image_data_url(&url)
.await
.ok_or_else(|| "image fetch failed".to_string())
}
/// Upgrade a catalog cover to the highest-resolution image that is verifiably
/// the SAME artwork as `cover` (Apple / Deezer-max / — for MB cards — Cover Art
/// Archive). Returns a replacement URL, or `None` to keep `cover`. Does no
/// MusicBrainz lookup, so a whole page can resolve its covers in parallel.
#[tauri::command]
pub async fn catalog_cover(
app: tauri::AppHandle,
state: State<'_, LibraryState>,
artist_id: Option<u64>,
artist: String,
title: String,
cover: String,
) -> Result<Option<String>, String> {
if cover.is_empty() {
return Ok(None);
}
if let Some(best) = load_cover_resolution(&app, artist_id, &artist, &title, &cover).await {
return Ok(Some(best));
}
let resolved = state
.metadata
.resolve_cover_for_artist_id(artist_id, &artist, &title, &cover)
.await;
if let Some(best_url) = resolved.as_ref() {
if let Ok(path) = cover_resolution_cache_path(&app, artist_id, &artist, &title, &cover) {
let best_url = best_url.clone();
// Finish the tiny atomic JSON write before exposing the URL. If it
// were detached, a very fast browser failure could invalidate a
// not-yet-written file and the late writer would resurrect that
// proven-bad URL for the next 30 days.
let result =
tokio::task::spawn_blocking(move || store_cover_resolution(&path, &best_url)).await;
if let Ok(Err(error)) = result {
log::warn!("could not cache cover resolution: {error}");
}
}
}
Ok(resolved)
}
/// Forget one persisted positive resolution after the browser proves its URL
/// unusable. The exact artist/title/reference key prevents a flaky edition
/// from evicting any other album's warm result.
#[tauri::command]
pub async fn invalidate_catalog_cover(
app: tauri::AppHandle,
artist_id: Option<u64>,
artist: String,
title: String,
cover: String,
) -> Result<(), String> {
let path = cover_resolution_cache_path(&app, artist_id, &artist, &title, &cover)?;
match tokio::fs::remove_file(path).await {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.to_string()),
}
}
struct HeroTempFiles(Vec<std::path::PathBuf>);
impl Drop for HeroTempFiles {
fn drop(&mut self) {
for path in &self.0 {
let _ = std::fs::remove_file(path);
}
}
}
fn scaled_height(source_width: u32, source_height: u32, target_width: u32) -> u32 {
(u64::from(source_height) * u64::from(target_width) / u64::from(source_width)).max(1) as u32
}
fn fidelityfx_cli_path(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
let bundled = app
.path()
.resolve(
"resources/FidelityFX_CLI.exe",
tauri::path::BaseDirectory::Resource,
)
.map_err(|error| error.to_string())?;
if bundled.is_file() {
return Ok(bundled);
}
// Tauri resolves bundled resources at runtime. This fallback keeps direct
// `cargo run` and Rust-side tests useful before the bundle is assembled.
let development = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("resources")
.join("FidelityFX_CLI.exe");
if development.is_file() {
return Ok(development);
}
Err("bundled FidelityFX upscaler is unavailable".into())
}
fn run_fidelityfx(cli: &std::path::Path, args: &[String]) -> Result<(), String> {
let mut command = std::process::Command::new(cli);
command.args(args);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt as _;
command.creation_flags(0x08000000);
}
let output = command.output().map_err(|error| error.to_string())?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
Err(format!(
"FidelityFX failed ({}): {}{}",
output.status,
stdout.trim(),
stderr.trim()
))
}
fn fsr_upscale_hero(
app: &tauri::AppHandle,
decoded: &image::DynamicImage,
source_width: u32,
source_height: u32,
) -> Result<image::DynamicImage, String> {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_JOB: AtomicU64 = AtomicU64::new(1);
let cli = fidelityfx_cli_path(app)?;
let work_dir = app
.path()
.app_cache_dir()
.map_err(|error| error.to_string())?
.join("hero-processing");
std::fs::create_dir_all(&work_dir).map_err(|error| error.to_string())?;
let id = NEXT_JOB.fetch_add(1, Ordering::Relaxed);
let prefix = format!("{}-{id}", std::process::id());
let input = work_dir.join(format!("{prefix}-input.png"));
let intermediate = work_dir.join(format!("{prefix}-intermediate.png"));
let easu = work_dir.join(format!("{prefix}-easu.png"));
let rcas = work_dir.join(format!("{prefix}-rcas.png"));
let _cleanup = HeroTempFiles(vec![
input.clone(),
intermediate.clone(),
easu.clone(),
rcas.clone(),
]);
decoded
.save_with_format(&input, image::ImageFormat::Png)
.map_err(|error| error.to_string())?;
// FSR1's documented performance ratio is 2x per dimension. A 720p source
// therefore reaches 4K in two EASU passes (720p -> 1440p -> 2160p), then
// receives one final RCAS pass at presentation resolution.
let first_width = source_width
.saturating_mul(2)
.min(ARTIST_HERO_PROVIDER_TARGET_WIDTH);
let mut current = input.clone();
if first_width < ARTIST_HERO_PROVIDER_TARGET_WIDTH {
let first_height = scaled_height(source_width, source_height, first_width);
run_fidelityfx(
&cli,
&[
"-Mode".into(),
"EASU".into(),
"-Scale".into(),
first_width.to_string(),
first_height.to_string(),
current.to_string_lossy().into_owned(),
intermediate.to_string_lossy().into_owned(),
],
)?;
current = intermediate.clone();
}
run_fidelityfx(
&cli,
&[
"-Mode".into(),
"EASU".into(),
"-Scale".into(),
ARTIST_HERO_PROVIDER_TARGET_WIDTH.to_string(),
ARTIST_HERO_PROVIDER_TARGET_HEIGHT.to_string(),
current.to_string_lossy().into_owned(),
easu.to_string_lossy().into_owned(),
],
)?;
run_fidelityfx(
&cli,
&[
"-Mode".into(),
"RCAS".into(),
"-Sharpness".into(),
ARTIST_HERO_FSR_SHARPNESS_STOPS.into(),
easu.to_string_lossy().into_owned(),
rcas.to_string_lossy().into_owned(),
],
)?;
image::open(rcas).map_err(|error| error.to_string())
}
fn smoothstep(edge0: f32, edge1: f32, value: f32) -> f32 {
let t = ((value - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
fn mix(left: f32, right: f32, amount: f32) -> f32 {
left + (right - left) * amount
}
fn ruby_chrome(color: [f32; 3]) -> [f32; 3] {
let luma = color[0] * 0.2126 + color[1] * 0.7152 + color[2] * 0.0722;
let shadow = 1.0 - smoothstep(0.16, 0.60, luma);
let highlight = smoothstep(0.38, 0.90, luma);
let mut graded = color.map(|channel| mix(luma, channel, 1.24));
graded = graded.map(|channel| (channel - 0.5) * 1.12 + 0.5);
graded = graded.map(|channel| {
let curved = channel * channel * (3.0 - 2.0 * channel);
mix(channel, curved, 0.18)
});
graded[0] += highlight * 0.070 + shadow * 0.018;
graded[1] -= highlight * 0.028;
graded[2] += shadow * 0.045 + highlight * 0.018;
graded.map(|channel| channel.clamp(0.0, 1.0))
}
/// Bake the approved look into the final 4K raster. The order is deliberate:
/// Ruby Chrome LUT -> vintage fade -> native-resolution monochrome grain.
fn apply_baked_hero_look(image: &mut image::RgbImage, source: &[u8]) {
for pixel in image.pixels_mut() {
let input = [
f32::from(pixel[0]) / 255.0,
f32::from(pixel[1]) / 255.0,
f32::from(pixel[2]) / 255.0,
];
let graded = ruby_chrome(input);
let color = [
mix(input[0], graded[0], ARTIST_HERO_LUT_STRENGTH),
mix(input[1], graded[1], ARTIST_HERO_LUT_STRENGTH),
mix(input[2], graded[2], ARTIST_HERO_LUT_STRENGTH),
];
let luma = color[0] * 0.2126 + color[1] * 0.7152 + color[2] * 0.0722;
let faded_color = [
color[0] * 0.78 + 0.085,
color[1] * 0.78 + 0.078,
color[2] * 0.78 + 0.068,
];
let faded_luma = luma * 0.78 + 0.078;
for channel in 0..3 {
let faded = mix(faded_luma, faded_color[channel], 0.88).clamp(0.0, 1.0);
let output = mix(color[channel], faded, ARTIST_HERO_FADE_STRENGTH);
pixel[channel] = (output.clamp(0.0, 1.0) * 255.0).round() as u8;
}
}
add_baked_hero_grain(image, source);
}
/// Deterministic triangular monochrome grain, generated one sample per final
/// 4K pixel and encoded with the image. It does not move, resize, or regenerate
/// with the window because it is part of the finished hero raster.
fn add_baked_hero_grain(image: &mut image::RgbImage, source: &[u8]) {
let stride = (source.len() / 4096).max(1);
let mut seed = 0xcbf29ce484222325_u64;
for byte in source.iter().step_by(stride) {
seed ^= u64::from(*byte);
seed = seed.wrapping_mul(0x100000001b3);
}
if seed == 0 {
seed = 0x9e3779b97f4a7c15;
}
let next = |state: &mut u64| {
*state ^= *state << 13;
*state ^= *state >> 7;
*state ^= *state << 17;
*state
};
let amplitude = (28.0 * ARTIST_HERO_GRAIN_STRENGTH).round() as i16;
for pixel in image.pixels_mut() {
let first = (next(&mut seed) & 0xff) as i16 - 128;
let second = (next(&mut seed) & 0xff) as i16 - 128;
let delta = ((first + second) * amplitude / 256).clamp(-amplitude, amplitude);
for channel in pixel.0.iter_mut() {
*channel = (i16::from(*channel) + delta).clamp(0, 255) as u8;
}
}
}
/// Crop letterbox bars baked into an image's edges — YouTube channel "TV art"
/// pads its safe area this way. A bar row/column is either near-black or
/// "murk": dark AND featureless (low brightness range), which catches the
/// blurry dark gradients YouTube composites between the bar and the artwork —
/// those read as broken letterboxing on the hero, and the approved behaviour
/// is a confident crop, never letterboxing. Conservative guards remain: a bar
/// must be a contiguous ≥2% band, each side is capped at a third, and the
/// crop is rejected outright if it would remove half the image — so a
/// genuinely dark photo cannot be eaten. Returns `None` when nothing trims.
fn trim_letterbox_bars(image: &image::DynamicImage) -> Option<image::DynamicImage> {
let rgb = image.to_rgb8();
let (w, h) = rgb.dimensions();
if w < 64 || h < 64 {
return None;
}
let barlike =
|luma_max: u8, luma_min: u8| luma_max < 16 || (luma_max < 60 && luma_max - luma_min < 48);
let dark_row = |y: u32| {
let mut max = 0u8;
let mut min = 255u8;
for x in (0..w).step_by(4) {
let p = rgb.get_pixel(x, y).0;
let l = p[0].max(p[1]).max(p[2]);
max = max.max(l);
min = min.min(l);
}
barlike(max, min)
};
let dark_col = |x: u32| {
let mut max = 0u8;
let mut min = 255u8;
for y in (0..h).step_by(4) {
let p = rgb.get_pixel(x, y).0;
let l = p[0].max(p[1]).max(p[2]);
max = max.max(l);
min = min.min(l);
}
barlike(max, min)
};
let mut top = 0;
while top < h / 3 && dark_row(top) {
top += 1;
}
let mut bottom = 0;
while bottom < h / 3 && dark_row(h - 1 - bottom) {
bottom += 1;
}
let mut left = 0;
while left < w / 3 && dark_col(left) {
left += 1;
}
let mut right = 0;
while right < w / 3 && dark_col(w - 1 - right) {
right += 1;
}
let min_band_h = (h / 50).max(4);
let min_band_w = (w / 50).max(4);
if top < min_band_h {
top = 0;
}
if bottom < min_band_h {
bottom = 0;
}
if left < min_band_w {
left = 0;
}
if right < min_band_w {
right = 0;
}
if top + bottom == 0 && left + right == 0 {
return None;
}
let new_w = w - left - right;
let new_h = h - top - bottom;
if new_w < w / 2 || new_h < h / 2 {
return None;
}
Some(image.crop_imm(left, top, new_w, new_h))
}
fn hero_source_checksum(bytes: &[u8]) -> String {
// This is an integrity/version key, not a security boundary. Keeping it as
// hex avoids losing 64-bit precision while the plan round-trips through JS.
use std::hash::{Hash as _, Hasher as _};
let mut checksum = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut checksum);
format!("{:016x}", checksum.finish())
}
fn valid_hero_crop_plan(
plan: &CatalogHeroCropPlan,
source_checksum: &str,
width: u32,
height: u32,
) -> bool {
if plan.recipe != ARTIST_HERO_CROP_RECIPE
|| plan.source_checksum != source_checksum
|| plan.geometry.source_width != width
|| plan.geometry.source_height != height
{
return false;
}
let Some(maximal) = maximal_hero_rect(width, height) else {
return false;
};
let crop = plan.geometry.crop;
crop.x == maximal.x
&& crop.width == maximal.width
&& crop.height == maximal.height
&& crop.width > 0
&& crop.height > 0
&& u64::from(crop.x) + u64::from(crop.width) <= u64::from(width)
&& u64::from(crop.y) + u64::from(crop.height) <= u64::from(height)
}
/// Decode, conservatively remove provider letterboxing, choose one immutable
/// source-pixel crop, and apply it before any preview resize or 4K work.
fn prepare_provider_hero(
app: &tauri::AppHandle,
bytes: &[u8],
trim_letterbox: bool,
supplied_plan: Option<CatalogHeroCropPlan>,
) -> Result<(image::DynamicImage, CatalogHeroCropPlan), String> {
use image::GenericImageView as _;
if bytes.len() as u64 > ARTIST_HERO_MAX_BYTES {
return Err("provider hero exceeds the 30 MB limit".into());
}
let mut decoded = image::load_from_memory(bytes).map_err(|error| error.to_string())?;
if trim_letterbox {
if let Some(trimmed) = trim_letterbox_bars(&decoded) {
decoded = trimmed;
}
}
let (width, height) = decoded.dimensions();
let source_checksum = hero_source_checksum(bytes);
let plan = match supplied_plan
.filter(|plan| valid_hero_crop_plan(plan, &source_checksum, width, height))
{
Some(plan) => plan,
None => {
let maximal = maximal_hero_rect(width, height)
.ok_or_else(|| "provider hero is too small to crop".to_string())?;
let (subjects, faces) = if maximal.height < height {
match detect_subjects(app, &decoded) {
Ok(detections) => detections,
Err(error) => {
log::warn!("content-aware artist hero crop unavailable: {error}");
(Vec::new(), Vec::new())
}
}
} else {
// Exact/wide sources have no vertical freedom, so inference
// could not affect the result and is deliberately skipped.
(Vec::new(), Vec::new())
};
let geometry = plan_hero_crop(width, height, &subjects, &faces)
.ok_or_else(|| "provider hero crop could not be planned".to_string())?;
log::info!(
"artist hero crop {}x{} -> x{} y{} {}x{} ({} people, {} faces, aware={})",
width,
height,
geometry.crop.x,
geometry.crop.y,
geometry.crop.width,
geometry.crop.height,
geometry.subject_count,
geometry.associated_face_count,
geometry.content_aware,
);
CatalogHeroCropPlan {
recipe: ARTIST_HERO_CROP_RECIPE.to_string(),
source_checksum,
geometry,
}
}
};
let crop = plan.geometry.crop;
Ok((
decoded.crop_imm(crop.x, crop.y, crop.width, crop.height),
plan,
))
}
fn provider_hero_preview_data_url(
app: &tauri::AppHandle,
bytes: &[u8],
trim_letterbox: bool,
) -> Result<CatalogHeroPreviewResult, String> {
use base64::Engine as _;
use image::GenericImageView as _;
let (cropped, plan) = prepare_provider_hero(app, bytes, trim_letterbox, None)?;
let (width, _) = cropped.dimensions();
// Preview never upscales. Rounding down to a 16-pixel width makes its
// encoded dimensions exactly 16:9 while losing at most 15 source pixels.
let preview_width = width.min(ARTIST_HERO_PREVIEW_MAX_WIDTH).max(16) / 16 * 16;
let preview_height = preview_width / 16 * 9;
let preview = cropped.resize_exact(
preview_width,
preview_height,
image::imageops::FilterType::CatmullRom,
);
let rgb = preview.to_rgb8();
let mut output = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 90)
.encode(
&rgb,
preview_width,
preview_height,
image::ExtendedColorType::Rgb8,
)
.map_err(|error| error.to_string())?;
Ok(CatalogHeroPreviewResult {
data_url: format!(
"data:image/jpeg;base64,{}",
base64::engine::general_purpose::STANDARD.encode(output)
),
plan,
})
}
/// Lossless, pre-cropped source pixels for the live GPU path. Cropping is
/// completed here, before the WebView sees the raster, so EASU cannot sample
/// discarded pixels around a crop edge. The source-bound grain seed keeps the
/// 4K grain field stable across visits and window sizes.
fn provider_hero_gpu_payload(
app: &tauri::AppHandle,
bytes: &[u8],
trim_letterbox: bool,
supplied_plan: Option<CatalogHeroCropPlan>,
) -> Result<Vec<u8>, String> {
use image::GenericImageView as _;
let (cropped, plan) = prepare_provider_hero(app, bytes, trim_letterbox, supplied_plan)?;
let (width, height) = cropped.dimensions();
let rgba = cropped.to_rgba8().into_raw();
let expected = u64::from(width)
.checked_mul(u64::from(height))
.and_then(|pixels| pixels.checked_mul(4))
.ok_or_else(|| "provider hero dimensions overflow".to_string())?;
if rgba.len() as u64 != expected {
return Err("provider hero RGBA payload has an invalid length".into());
}
// WebGL floats represent every integer exactly only through 24 bits.
let grain_seed = u64::from_str_radix(&plan.source_checksum, 16)
.unwrap_or(1)
.wrapping_mul(0x9e37_79b9)
.rotate_left(17) as u32
& 0x00ff_ffff;
let mut payload = Vec::with_capacity(CATALOG_HERO_GPU_HEADER_BYTES + rgba.len());
payload.extend_from_slice(CATALOG_HERO_GPU_MAGIC);
payload.extend_from_slice(&width.to_le_bytes());
payload.extend_from_slice(&height.to_le_bytes());
payload.extend_from_slice(&grain_seed.max(1).to_le_bytes());
payload.extend_from_slice(&rgba);
Ok(payload)
}
fn provider_hero_data_url(
app: &tauri::AppHandle,
bytes: &[u8],
upscaler: config::ArtistHeroUpscaler,
trim_letterbox: bool,
supplied_plan: Option<CatalogHeroCropPlan>,
) -> Result<(String, bool), String> {
use base64::Engine as _;
use image::GenericImageView as _;
let (decoded, _) = prepare_provider_hero(app, bytes, trim_letterbox, supplied_plan)?;
let (width, height) = decoded.dimensions();
let (finished, cacheable) = if width == ARTIST_HERO_PROVIDER_TARGET_WIDTH
&& height == ARTIST_HERO_PROVIDER_TARGET_HEIGHT
{
(decoded, true)
} else if width > ARTIST_HERO_PROVIDER_TARGET_WIDTH {
// More-than-4K sources need a high-quality downsample, not an upscale.
(
decoded.resize_exact(
ARTIST_HERO_PROVIDER_TARGET_WIDTH,
ARTIST_HERO_PROVIDER_TARGET_HEIGHT,
image::imageops::FilterType::Lanczos3,
),
true,
)
} else {
match upscaler {
config::ArtistHeroUpscaler::Fsr => {
match fsr_upscale_hero(app, &decoded, width, height) {
Ok(image) => (
if image.dimensions()
== (
ARTIST_HERO_PROVIDER_TARGET_WIDTH,
ARTIST_HERO_PROVIDER_TARGET_HEIGHT,
)
{
image
} else {
image.resize_exact(
ARTIST_HERO_PROVIDER_TARGET_WIDTH,
ARTIST_HERO_PROVIDER_TARGET_HEIGHT,
image::imageops::FilterType::Lanczos3,
)
},
true,
),
Err(error) => {
log::warn!("FSR hero upscale failed, using Lanczos3: {error}");
(
decoded
.resize_exact(
ARTIST_HERO_PROVIDER_TARGET_WIDTH,
ARTIST_HERO_PROVIDER_TARGET_HEIGHT,
image::imageops::FilterType::Lanczos3,
)
.unsharpen(0.7, 2),
// Display the resilient fallback, but do not preserve it
// under the FSR key. A later visit should retry the
// quality mode the user actually selected.
false,
)
}
}
}
config::ArtistHeroUpscaler::LiveGpu | config::ArtistHeroUpscaler::Lanczos3 => (
decoded
.resize_exact(
ARTIST_HERO_PROVIDER_TARGET_WIDTH,
ARTIST_HERO_PROVIDER_TARGET_HEIGHT,
image::imageops::FilterType::Lanczos3,
)
.unsharpen(0.7, 2),
true,
),
}
};
let mut rgb = finished.to_rgb8();
apply_baked_hero_look(&mut rgb, bytes);
let mut output = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 93)
.encode(
&rgb,
ARTIST_HERO_PROVIDER_TARGET_WIDTH,
ARTIST_HERO_PROVIDER_TARGET_HEIGHT,
image::ExtendedColorType::Rgb8,
)
.map_err(|error| error.to_string())?;
Ok((
format!(
"data:image/jpeg;base64,{}",
base64::engine::general_purpose::STANDARD.encode(output)
),
cacheable,
))
}
/// Hero-only proxy. Unlike `catalog_art`, this may upscale a 720p provider
/// image. Keeping the commands separate protects album covers and logo PNGs
/// from lossy conversion.
#[tauri::command]
pub async fn catalog_hero_preview(
app: tauri::AppHandle,
state: State<'_, LibraryState>,
url: String,
) -> Result<CatalogHeroPreviewResult, String> {
if !url.starts_with("https://") {
return Err("not an https url".into());
}
let bytes = state
.metadata
.hero_bytes(&url)
.await
.ok_or_else(|| "hero image fetch failed".to_string())?;
let trim_letterbox = url.starts_with("https://yt3.googleusercontent.com/")
|| url.starts_with("https://yt3.ggpht.com/");
let worker_app = app.clone();
tokio::task::spawn_blocking(move || {
provider_hero_preview_data_url(&worker_app, &bytes, trim_letterbox)
})
.await
.map_err(|error| error.to_string())?
}
/// Crop-first, lossless input for the fixed-4K GPU renderer. Raw binary IPC
/// returns an ArrayBuffer rather than a JSON number array or base64 string.
#[tauri::command]
pub async fn catalog_hero_gpu_source(
app: tauri::AppHandle,
state: State<'_, LibraryState>,
url: String,
plan: Option<CatalogHeroCropPlan>,
) -> Result<tauri::ipc::Response, String> {
if !url.starts_with("https://") {
return Err("not an https url".into());
}
if config::load(&app).artist_hero_upscaler != config::ArtistHeroUpscaler::LiveGpu {
return Err("live GPU hero rendering is disabled".into());
}
let bytes = state
.metadata
.hero_bytes(&url)
.await
.ok_or_else(|| "hero image fetch failed".to_string())?;
let trim_letterbox = url.starts_with("https://yt3.googleusercontent.com/")
|| url.starts_with("https://yt3.ggpht.com/");
// Autocomplete warming and rapid artist navigation can leave work for
// different sources in flight. Bound the decode/crop/detector phase so
// those stale jobs cannot multiply peak CPU and decoded-raster memory.
let prepare_slot = artist_hero_gpu_prepare_slots()
.acquire()
.await
.map_err(|_| "live GPU hero preparation is unavailable".to_string())?;
let worker_app = app.clone();
let payload = tokio::task::spawn_blocking(move || {
// Keep the permit inside the blocking job. If rapid navigation drops
// the command future, Tokio detaches an already-running blocking task;
// a permit owned only by the async frame would be released early and
// stale decode/YOLO jobs could again exceed the global bound.
let _prepare_slot = prepare_slot;
provider_hero_gpu_payload(&worker_app, &bytes, trim_letterbox, plan)
})
.await
.map_err(|error| error.to_string())??;
Ok(tauri::ipc::Response::new(payload))
}
#[tauri::command]
pub async fn catalog_cached_hero_art(
app: tauri::AppHandle,
url: String,
) -> Result<Option<String>, String> {
if !url.starts_with("https://") {
return Err("not an https url".into());
}
let upscaler = config::load(&app).artist_hero_upscaler;
if upscaler == config::ArtistHeroUpscaler::LiveGpu {
return Ok(None);
}
let cache_path = finished_hero_cache_path(&app, &url, upscaler)?;
Ok(load_finished_hero(&cache_path).await)
}
#[tauri::command]
pub async fn catalog_hero_art(
app: tauri::AppHandle,
state: State<'_, LibraryState>,
url: String,
plan: Option<CatalogHeroCropPlan>,
) -> Result<CatalogHeroArtResult, String> {
if !url.starts_with("https://") {
return Err("not an https url".into());
}
let upscaler = config::load(&app).artist_hero_upscaler;
if upscaler == config::ArtistHeroUpscaler::LiveGpu {
return Err("live GPU heroes are not baked".into());
}
let cache_path = finished_hero_cache_path(&app, &url, upscaler)?;
if let Some(cached) = load_finished_hero(&cache_path).await {
return Ok(CatalogHeroArtResult {
data_url: cached,
cacheable: true,
});
}
let bytes = state
.metadata
.hero_bytes(&url)
.await
.ok_or_else(|| "hero image fetch failed".to_string())?;
// Only YouTube channel banners are known to bake letterbox bars into the
// raster; the trim never runs on curated art-database sources.
let trim_letterbox = url.starts_with("https://yt3.googleusercontent.com/")
|| url.starts_with("https://yt3.ggpht.com/");
let worker_app = app.clone();
tokio::task::spawn_blocking(move || {
let (data_url, cacheable) =
provider_hero_data_url(&worker_app, &bytes, upscaler, trim_letterbox, plan)?;
if cacheable {
if let Err(error) = store_finished_hero(&cache_path, &data_url) {
log::warn!("could not cache finished artist hero: {error}");
}
}
Ok(CatalogHeroArtResult {
data_url,
cacheable,
})
})
.await
.map_err(|error| error.to_string())?
}
#[tauri::command]
pub async fn catalog_album(
state: State<'_, LibraryState>,
album_id: u64,
) -> Result<metadata::CatalogAlbumPage, String> {
state.touch_catalog_activity();
state
.metadata
.catalog_album(album_id)
.await
.ok_or_else(|| "album lookup failed".to_string())
}
/// Editorial context (description/genre/label) for one release — TheAudioDB
/// first, Wikipedia summary fallback. Always succeeds; empty fields mean
/// neither source knew the record.
#[tauri::command]
pub async fn catalog_album_info(
state: State<'_, LibraryState>,
artist_id: Option<u64>,
artist: String,
title: String,
) -> Result<metadata::AlbumInfo, String> {
state.touch_catalog_activity();
Ok(state
.metadata
.album_info_for_artist_id(artist_id, &artist, &title)
.await)
}
#[tauri::command]
pub async fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
if let Some(client) = state.client.lock().await.take() {
client.shutdown();
}
*state.connected_as.lock().await = None;
Ok(())
}
#[tauri::command]
pub async fn start_search(state: State<'_, AppState>, query: String) -> Result<u32, String> {
let guard = state.client.lock().await;
let client = guard.as_ref().ok_or("not connected")?;
client.search(&query).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn stop_search(state: State<'_, AppState>, token: u32) -> Result<(), String> {
if let Some(client) = state.client.lock().await.as_ref() {
client.stop_search(token);
}
Ok(())
}
#[tauri::command]
pub async fn download_file(
state: State<'_, AppState>,
username: String,
path: String,
size: u64,
) -> Result<u64, String> {
let guard = state.client.lock().await;
let client = guard.as_ref().ok_or("not connected")?;
Ok(client.download(&username, &path, size))
}
/// A streamed file's peer identity, used to locate its cached copy.
#[derive(serde::Deserialize)]
pub struct StreamRef {
pub username: String,
pub path: String,
}
/// A streamed file plus its size, for saving into the library.
#[derive(serde::Deserialize)]
pub struct SaveRef {
pub username: String,
pub path: String,
pub size: u64,
}
fn sanitize_seg(part: &str) -> String {
part.chars()
.map(|c| if "<>:\"/\\|?*".contains(c) { '_' } else { c })
.collect::<String>()
.trim()
.to_string()
}
/// Save the file(s) you're streaming into the library. Cached tracks are
/// organized straight from the stream cache — no re-download, and crucially no
/// second transfer of a file the peer is already streaming to us (which
/// deadlocks both). Only tracks that aren't cached yet are downloaded.
/// Returns (organized_from_cache, queued_for_download).
#[tauri::command]
pub async fn save_to_library(
app: tauri::AppHandle,
state: State<'_, AppState>,
files: Vec<SaveRef>,
) -> Result<(u32, u32), String> {
let downloads_dir = config::download_dir(&app, &config::load(&app));
let mut organized = 0u32;
let mut queued = 0u32;
for f in files {
// 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;
continue;
}
}
// The file may already be arriving as the playing stream or an
// album-continuation preload. Subscribe to that transfer instead of
// opening a duplicate peer request that can remain queued forever.
if let Some(players) = app.try_state::<PlayerState>() {
let cache = crate::playback::cache_path(&app, &f.username, &f.path);
let transfer = cache.as_ref().and_then(|cache| {
players
.preloads
.lock()
.unwrap()
.iter()
.find(|(_, target)| *target == cache)
.map(|(id, _)| *id)
.or_else(|| {
players
.stream_caches
.lock()
.unwrap()
.iter()
.find(|(_, target)| *target == cache)
.map(|(id, _)| *id)
})
});
if let (Some(id), Some(cache)) = (transfer, cache) {
players.deferred_saves.lock().unwrap().insert(id, cache);
queued += 1;
continue;
}
}
// Not cached — fetch the known file (still no re-search).
let guard = state.client.lock().await;
if let Some(client) = guard.as_ref() {
client.download(&f.username, &f.path, f.size);
queued += 1;
}
}
let _ = app.emit("soulseek:library-changed", ());
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(
app: &tauri::AppHandle,
downloads_dir: &std::path::Path,
cache: &std::path::Path,
remote_path: &str,
) -> Result<bool, String> {
let (folder, name) = match remote_path.rsplit_once('\\') {
Some((dir, n)) => (dir.rsplit('\\').next().unwrap_or(""), n),
None => ("", remote_path),
};
let mut dir = downloads_dir.join("stream-save");
if !folder.is_empty() {
dir.push(sanitize_seg(folder));
}
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let dest = dir.join(sanitize_seg(name));
if std::fs::copy(cache, &dest).is_err() {
return Ok(false);
}
crate::pipeline::process_file(app, &dest, Some(remote_path)).await;
Ok(true)
}
/// Copy the files being streamed (already in the stream cache) into a chosen
/// folder — no re-download, no re-search. Skips any track not yet cached.
/// Returns how many were saved and how many weren't cached.
#[tauri::command]
pub async fn copy_stream_to_folder(
app: tauri::AppHandle,
files: Vec<StreamRef>,
folder: String,
) -> Result<(u32, u32), String> {
let dest = std::path::PathBuf::from(&folder);
std::fs::create_dir_all(&dest).map_err(|e| e.to_string())?;
let mut saved = 0u32;
let mut missing = 0u32;
for f in files {
let Some(cache) = crate::playback::cache_path(&app, &f.username, &f.path) else {
missing += 1;
continue;
};
if !cache.exists() {
missing += 1;
continue;
}
let name = f.path.rsplit(['\\', '/']).next().unwrap_or("track");
match std::fs::copy(&cache, dest.join(name)) {
Ok(_) => saved += 1,
Err(_) => missing += 1,
}
}
Ok((saved, missing))
}
#[tauri::command]
pub async fn cancel_download(state: State<'_, AppState>, id: u64) -> Result<(), String> {
if let Some(client) = state.client.lock().await.as_ref() {
client.cancel_download(id);
}
Ok(())
}
/// Reveal a completed download in Explorer.
#[tauri::command]
pub fn show_in_folder(path: String) {
let _ = std::process::Command::new("explorer")
.args(["/select,", &path])
.spawn();
}
#[tauri::command]
pub async fn refresh_artist_art(app: tauri::AppHandle) -> Result<usize, String> {
crate::pipeline::refresh_all_artist_art(&app).await
}
/// Stream a remote file: download into the cache and start playback the
/// moment bytes arrive.
#[tauri::command]
pub async fn play_stream(
app: tauri::AppHandle,
state: State<'_, AppState>,
players: State<'_, PlayerState>,
username: String,
path: String,
size: u64,
duration: Option<f64>,
) -> Result<(), String> {
let cache = playback::cache_path(&app, &username, &path).ok_or("no cache dir")?;
// Fully cached from an earlier stream → play directly. Touch it so
// retention counts from this play, not the original stream.
if let Ok(md) = std::fs::metadata(&cache) {
if size > 0 && md.len() == size {
playback::touch(&cache);
crate::diagnostics::clear_stream(&app);
// This track never becomes a transfer: there is no StreamProgress
// and no download task, so the update loop below that emits
// `soulseek:stream` never runs for it. Say it once, or the UI keeps
// the provisional counts it set when it asked to play and shows a
// download pinned at 0% — or, coming out of a gapless advance, an
// indeterminate "Streaming from <peer>" — on a file that is already
// complete. `done` also lets the frontend take its completed-track
// path (final waveform, pre-buffer the next).
let _ = app.emit(
"soulseek:stream",
serde_json::json!({
"received": size,
"total": size,
"done": true,
"failed": Option::<String>::None,
}),
);
let app2 = app.clone();
let cache2 = cache.clone();
crate::loudness::on_track_started(
&app,
&cache,
size,
None,
Box::new(move || {
app2.state::<PlayerState>()
.player
.play_local(&cache2, duration);
}),
);
return Ok(());
}
let _ = std::fs::remove_file(&cache);
}
let partial = playback::partial_path(&cache);
// A preload may already be fetching this exact file: attach to it and
// keep the bytes it has instead of restarting the transfer.
let preload_id = players
.preloads
.lock()
.unwrap()
.iter()
.find(|(_, c)| **c == cache)
.map(|(id, _)| *id);
if let Some(id) = preload_id {
players.preloads.lock().unwrap().remove(&id);
// From here the transfer is the CURRENT track's stream; the overlay's
// DL line (note_stream below) reports it, not the on-deck list.
crate::diagnostics::retire_preload(&app, id);
let progress = player::StreamProgress::new();
if let Ok(md) = std::fs::metadata(&partial) {
progress.update(md.len(), false, None);
}
players.streams.lock().unwrap().insert(id, progress.clone());
players
.stream_caches
.lock()
.unwrap()
.insert(id, cache.clone());
crate::diagnostics::note_stream(&app, size, progress.clone());
let app2 = app.clone();
let partial2 = partial.clone();
let progress2 = progress.clone();
crate::loudness::on_track_started(
&app,
&cache,
size,
Some((partial, progress)),
Box::new(move || {
app2.state::<PlayerState>()
.player
.play_stream(&partial2, progress2, size, duration);
}),
);
return Ok(());
}
let _ = std::fs::remove_file(&partial);
let progress = player::StreamProgress::new();
let id = {
let guard = state.client.lock().await;
let client = guard.as_ref().ok_or("not connected")?;
client.download_to(&username, &path, size, cache.clone())
};
players.streams.lock().unwrap().insert(id, progress.clone());
players
.stream_caches
.lock()
.unwrap()
.insert(id, cache.clone());
crate::diagnostics::note_stream(&app, size, progress.clone());
let app2 = app.clone();
let partial2 = partial.clone();
let progress2 = progress.clone();
crate::loudness::on_track_started(
&app,
&cache,
size,
Some((partial, progress)),
Box::new(move || {
app2.state::<PlayerState>()
.player
.play_stream(&partial2, progress2, size, duration);
}),
);
Ok(())
}
/// Quietly download the next queue track into the stream cache so playback
/// can move to it without a buffering gap. No player, UI, or library
/// side-effects; play_stream attaches to it when the track comes up.
#[tauri::command]
pub async fn preload_stream(
app: tauri::AppHandle,
state: State<'_, AppState>,
players: State<'_, PlayerState>,
username: String,
path: String,
size: u64,
) -> Result<(), String> {
let cache = playback::cache_path(&app, &username, &path).ok_or("no cache dir")?;
if let Ok(md) = std::fs::metadata(&cache) {
if size > 0 && md.len() == size {
// Gapless normalization: boundary gain ready before the switch.
crate::loudness::on_preload_ready(&app, &cache);
return Ok(()); // already fully cached
}
let _ = std::fs::remove_file(&cache);
}
if players
.preloads
.lock()
.unwrap()
.values()
.any(|c| *c == cache)
{
return Ok(()); // already preloading
}
let _ = std::fs::remove_file(&playback::partial_path(&cache));
let id = {
let guard = state.client.lock().await;
let client = guard.as_ref().ok_or("not connected")?;
client.download_to(&username, &path, size, cache.clone())
};
players.preloads.lock().unwrap().insert(id, cache);
Ok(())
}
/// Append a fully cached stream track behind the active source so the
/// transition happens inside the same audio sink (true gapless). Returns
/// false — and does nothing — while the cache file is still incomplete.
#[tauri::command]
pub fn preload_cached_stream(
app: tauri::AppHandle,
players: State<'_, PlayerState>,
username: String,
path: String,
size: u64,
duration: Option<f64>,
) -> Result<bool, String> {
let cache = playback::cache_path(&app, &username, &path).ok_or("no cache dir")?;
match std::fs::metadata(&cache) {
Ok(md) if size == 0 || md.len() == size => {
playback::touch(&cache);
players.player.preload_local(&cache, duration);
// Gapless normalization: boundary gain ready before the switch.
crate::loudness::on_preload_ready(&app, &cache);
Ok(true)
}
_ => Ok(false),
}
}
/// Waveform peaks for a stream-cached track. While the download is still in
/// flight, the partial file is decoded and its peaks scaled into the leading
/// `received/size` share of the buckets (rest zero), so the waveform appears
/// immediately and grows with the transfer. Complete files are memoized.
#[tauri::command]
pub async fn stream_waveform(
app: tauri::AppHandle,
players: State<'_, PlayerState>,
username: String,
path: String,
size: u64,
buckets: usize,
) -> Result<WaveformSlice, String> {
const MAX_WAVEFORM_BUCKETS: usize = 4096;
if buckets > MAX_WAVEFORM_BUCKETS {
return Err(format!(
"waveform bucket count exceeds {MAX_WAVEFORM_BUCKETS}"
));
}
if buckets == 0 {
return Ok(WaveformSlice::empty());
}
let Some(cache) = playback::cache_path(&app, &username, &path) else {
return Ok(WaveformSlice::empty());
};
let key = format!("{}#{buckets}", cache.display());
let job = players.waveform_job(&key);
let result = async {
// Re-check the filesystem only after acquiring the per-key lock: a
// partial decoder ahead of us may have run while the transfer renamed
// the completed file into place.
let job_guard = job.clone().lock_owned().await;
if matches!(std::fs::metadata(&cache), Ok(md) if size == 0 || md.len() == size) {
players.remove_progressive_waveform(&key);
if let Some(cached) = players.cached_waveform(&key) {
return Ok(WaveformSlice::complete(cached));
}
let p = cache.clone();
let (job_guard, peaks) = tauri::async_runtime::spawn_blocking(move || {
let peaks = player::compute_waveform(&p, buckets);
(job_guard, peaks)
})
.await
.map_err(|e| e.to_string())?;
if !peaks.is_empty() {
players.cache_waveform(key.clone(), peaks.clone());
}
drop(job_guard);
return Ok(WaveformSlice::complete(peaks));
}
// Seeing a partial file invalidates any completed waveform retained
// under the same stable cache path from an earlier transfer.
players.remove_cached_waveform(&key);
let partial = playback::partial_path(&cache);
let have = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0);
if have == 0 || size == 0 {
return Ok(WaveformSlice::empty());
}
if let Some(previous) = players.progressive_waveform(&key) {
if !previous.should_recompute(have) {
return Ok(WaveformSlice {
coverage: (previous.decoded_bytes() as f64 / size as f64).min(1.0) as f32,
peaks: previous.peaks(),
});
}
}
// Byte share ≈ time share (close enough for a scrubber even on VBR).
// The cached threshold doubles after every useful decode, bounding all
// partial-file work geometrically instead of decoding from zero every
// three seconds.
let frac = (have as f64 / size as f64).min(1.0);
let n = ((buckets as f64 * frac).round() as usize).clamp(1, buckets);
let (job_guard, peaks) = tauri::async_runtime::spawn_blocking(move || {
let peaks = player::compute_waveform(&partial, n);
(job_guard, peaks)
})
.await
.map_err(|e| e.to_string())?;
if !peaks.is_empty() {
players.cache_progressive_waveform(
key.clone(),
playback::ProgressiveWaveform::new(have, size, peaks.clone()),
);
}
drop(job_guard);
// The peaks are ONLY the decoded audio — no zero padding to the full
// bucket count any more. The old padding made the array's length lie
// about its contents: the UI folded real-peaks-plus-zero-tail across a
// frontier computed from a FRESHER byte count, so the drawn shape
// stretched between refreshes and the loudness scale ate the zeros.
// Coverage says exactly how much of the track these peaks describe;
// where the standing bars end IS how much of the track has loaded.
Ok(WaveformSlice {
coverage: frac as f32,
peaks,
})
}
.await;
players.release_waveform_job(&key, &job);
result
}
/// Is this exact stream source already fully cached on disk?
///
/// The catalog's play paths ask BEFORE running a Soulseek resolve: a complete
/// cache file can play immediately — even offline — and the search would only
/// rediscover the peer that filled it. Anything less than a byte-complete
/// file answers false, so eviction and half-finished transfers fall back to
/// the normal resolver on their own.
#[tauri::command]
pub fn stream_cache_ready(
app: tauri::AppHandle,
username: String,
path: String,
size: u64,
) -> bool {
let Some(cache) = playback::cache_path(&app, &username, &path) else {
return false;
};
size > 0 && matches!(std::fs::metadata(&cache), Ok(md) if md.len() == size)
}
/// A waveform plus how much of the track it actually describes. `coverage`
/// is the byte share of the transfer the peaks were decoded from (1.0 for a
/// complete file); the peaks span exactly that prefix of the track and
/// nothing more.
#[derive(Clone, serde::Serialize)]
pub struct WaveformSlice {
coverage: f32,
peaks: Vec<f32>,
}
impl WaveformSlice {
fn empty() -> Self {
WaveformSlice {
coverage: 0.0,
peaks: Vec::new(),
}
}
fn complete(peaks: Vec<f32>) -> Self {
WaveformSlice {
coverage: 1.0,
peaks,
}
}
}
/// Play an already-downloaded local file.
#[tauri::command]
pub fn play_local(
app: tauri::AppHandle,
path: String,
duration: Option<f64>,
start_secs: Option<f64>,
) -> Result<(), String> {
if std::fs::metadata(&path).is_err() {
return Err("file not found".into());
}
crate::diagnostics::clear_stream(&app);
let app2 = app.clone();
let path2 = path.clone();
crate::loudness::on_track_started(
&app,
std::path::Path::new(&path),
0,
None,
Box::new(move || {
// `start_secs` = play a SLICE of the file from there (the
// setlist segment rows); plain tracks pass None.
app2.state::<PlayerState>()
.player
.play_local_from(&path2, duration, start_secs);
}),
);
Ok(())
}
/// Decode a local track now and append it behind the active source. This is
/// used for gapless album playback; it has no effect on streaming downloads.
#[tauri::command]
pub fn preload_local(
app: tauri::AppHandle,
players: State<'_, PlayerState>,
path: String,
duration: Option<f64>,
) -> Result<(), String> {
if std::fs::metadata(&path).is_err() {
return Err("file not found".into());
}
players.player.preload_local(&path, duration);
// Gapless normalization: have the boundary gain ready before the switch.
crate::loudness::on_preload_ready(&app, std::path::Path::new(&path));
Ok(())
}
#[tauri::command]
pub async fn library_albums(lib: State<'_, LibraryState>) -> Result<Vec<library::Album>, String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.list_albums().map_err(|e| e.to_string()))
.await
.map_err(|e| e.to_string())?
}
/// Per-album aggregates (length, bytes, bitrate, formats, last played) for the
/// Library page — one call for the whole library. `min_secs` is the play floor:
/// the frontend passes the same RECENTS_FLOOR_SECS the recents list uses.
#[tauri::command]
pub async fn library_album_stats(
lib: State<'_, LibraryState>,
min_secs: u32,
) -> Result<Vec<library::AlbumStats>, String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || {
l.album_stats(min_secs).map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn album_tracks(
lib: State<'_, LibraryState>,
album_id: i64,
) -> Result<Vec<library::Track>, String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || {
l.album_tracks(album_id).map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
/// Force-refetch an album's cover art and re-embed it into its tracks.
#[tauri::command]
pub async fn refresh_album_art(app: tauri::AppHandle, album_id: i64) -> Result<String, String> {
pipeline::refresh_album_art(&app, album_id).await
}
/// Upgrade every library album to the canonical high-resolution cover
/// (refetch + re-embed + folder covers). Returns how many succeeded.
#[tauri::command]
pub async fn refresh_all_album_art(app: tauri::AppHandle) -> Result<usize, String> {
pipeline::refresh_all_album_art(&app).await
}
/// Album art as a data URL (small files; avoids asset-protocol scopes).
///
/// With `max_edge` the art comes back as a Lanczos3 thumbnail bounded to that
/// many pixels on its long side — what every tile wants. Masters here run to
/// 2000px+, and handing one of those to a 132px card makes the webview do a
/// 15x single-step reduction; `thumbnail_jpeg` explains why that aliases. It
/// also cuts the base64 a tile carries from megabytes to tens of kilobytes.
///
/// Callers that must show TRUE pixels — the fullscreen viewer, the import
/// picker's A/B and its loupe — pass no `max_edge` and get the file untouched.
/// 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(
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| art_thumb_target(&dir, &probe_path, edge));
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,{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
))
}
/// 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 disk-cache file for one (source, edge) thumbnail, keyed on
/// (path, mtime, len, edge) so a replaced cover self-invalidates. ONE
/// implementation on purpose: `album_art` reads through this key and
/// `library_art_prewarm` writes through it — if they computed it separately
/// and drifted, the warm work would be invisible to the tiles it was done
/// for. None when the source file cannot be statted.
fn art_thumb_target(dir: &std::path::Path, src: &str, edge: u32) -> Option<std::path::PathBuf> {
let meta = std::fs::metadata(src).ok()?;
let mtime = meta
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_nanos();
let mut hash: u64 = 0xcbf29ce484222325;
for b in src
.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")))
}
/// Make the thumbnail cache hold every library cover (and artist portrait) at
/// each requested edge, so the Library's first-ever scroll is a disk read
/// instead of a master decode — [{warm, decoded}] per edge, in the order
/// given, so the caller puts the size it paints first.
///
/// Already-warm entries cost one stat each. Cold ones decode ONE at a time,
/// through the same 2-permit gate the tiles use — a background warm can never
/// own more than one core, and an on-screen tile waits behind at most one
/// decode. Edges walk outermost so the first edge finishes across the whole
/// library before the next begins.
#[tauri::command]
pub async fn library_art_prewarm(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
edges: Vec<u32>,
) -> Result<Vec<(usize, usize)>, String> {
let Some(thumb_dir) = app
.path()
.app_cache_dir()
.ok()
.map(|d| d.join("art-thumbs-v1"))
else {
return Ok(vec![]);
};
let l = lib.library.clone();
let albums =
tauri::async_runtime::spawn_blocking(move || l.list_albums().map_err(|e| e.to_string()))
.await
.map_err(|e| e.to_string())??;
let mut paths: Vec<String> = Vec::new();
let mut seen = std::collections::HashSet::new();
for a in &albums {
// Portraits too: the shelf heads load through the same tile path.
for p in [a.art_path.as_ref(), a.artist_art_path.as_ref()]
.into_iter()
.flatten()
{
if seen.insert(p.clone()) {
paths.push(p.clone());
}
}
}
let mut out = Vec::new();
for edge in edges {
if edge == 0 {
out.push((0, 0));
continue;
}
// One pass of cheap stats finds the cold set; the warm majority never
// touches a permit (the same shape as album_art's phase 1).
let dir = thumb_dir.clone();
let batch = paths.clone();
let (warm, cold) = tauri::async_runtime::spawn_blocking(move || {
let mut warm = 0usize;
let mut cold: Vec<(String, std::path::PathBuf)> = Vec::new();
for path in &batch {
match art_thumb_target(&dir, path, edge) {
Some(t) if t.exists() => warm += 1,
Some(t) => cold.push((path.clone(), t)),
None => {}
}
}
(warm, cold)
})
.await
.map_err(|e| e.to_string())?;
let mut decoded = 0usize;
for (path, target) in cold {
let permit = ART_DECODE_SLOTS
.acquire()
.await
.map_err(|e| e.to_string())?;
let ok = tauri::async_runtime::spawn_blocking(move || {
let Ok(bytes) = std::fs::read(&path) else {
return false;
};
// Same fallback as album_art: an undecodable or already-small
// source caches its original bytes, so the discovery is paid
// once there too.
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);
}
std::fs::write(&target, &out).is_ok()
})
.await
.map_err(|e| e.to_string())?;
drop(permit);
if ok {
decoded += 1;
}
}
out.push((warm, decoded));
}
Ok(out)
}
/// 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
/// this command instead.
#[tauri::command]
pub async fn open_external(url: String) -> Result<(), String> {
if !url.starts_with("https://") && !url.starts_with("http://") {
return Err("only http(s) URLs can be opened".into());
}
let spawned = {
#[cfg(target_os = "windows")]
{
std::process::Command::new("rundll32")
.args(["url.dll,FileProtocolHandler", &url])
.spawn()
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open").arg(&url).spawn()
}
#[cfg(all(unix, not(target_os = "macos")))]
{
std::process::Command::new("xdg-open").arg(&url).spawn()
}
};
spawned.map(|_| ()).map_err(|e| e.to_string())
}
// ---- play history ----
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn record_play(
lib: State<'_, LibraryState>,
artist: String,
title: String,
path: Option<String>,
catalog_album_id: Option<String>,
album_title: Option<String>,
album_artist: Option<String>,
cover_url: Option<String>,
) -> Result<i64, String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || {
let cat = library::CatalogPlay {
album_id: catalog_album_id.as_deref(),
title: album_title.as_deref(),
artist: album_artist.as_deref(),
cover: cover_url.as_deref(),
};
l.record_play(&artist, &title, path.as_deref(), cat, now_secs())
.map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
/// Wipe listening history. Destructive and not undoable — `plays` only;
/// albums, tracks and playlists are untouched. Returns rows removed.
#[tauri::command]
pub async fn clear_play_history(lib: State<'_, LibraryState>) -> Result<usize, String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || {
l.clear_play_history().map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn play_row_count(lib: State<'_, LibraryState>) -> Result<i64, String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.play_row_count().map_err(|e| e.to_string()))
.await
.map_err(|e| e.to_string())?
}
/// The sidebar's recently-played list. `min_secs` keeps skips out of it.
#[tauri::command]
pub async fn recent_albums(
lib: State<'_, LibraryState>,
limit: Option<u32>,
min_secs: Option<u32>,
) -> Result<Vec<library::RecentAlbum>, String> {
let l = lib.library.clone();
let limit = limit.unwrap_or(20);
let min_secs = min_secs.unwrap_or(45);
tauri::async_runtime::spawn_blocking(move || {
l.recent_albums(limit, min_secs).map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn add_play_seconds(
lib: State<'_, LibraryState>,
play_id: i64,
secs: u32,
) -> Result<(), String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || {
l.add_play_seconds(play_id, secs).map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn artist_play_stats(
lib: State<'_, LibraryState>,
artist: String,
) -> Result<library::PlayStats, String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || {
l.artist_play_stats(&artist).map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
// ---- followed artists ----
#[tauri::command]
pub async fn followed_artists(
lib: State<'_, LibraryState>,
) -> Result<Vec<library::FollowedArtist>, String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.followed_artists().map_err(|e| e.to_string()))
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn follow_artist(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
artist_id: u64,
name: String,
portrait: Option<String>,
) -> Result<(), String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || {
l.follow_artist(artist_id, &name, portrait.as_deref(), now_secs())
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let _ = app.emit("soulseek:follows-changed", ());
Ok(())
}
#[tauri::command]
pub async fn unfollow_artist(
app: tauri::AppHandle,
lib: State<'_, LibraryState>,
artist_id: u64,
) -> Result<(), String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.unfollow_artist(artist_id))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let _ = app.emit("soulseek:follows-changed", ());
Ok(())
}
// ---- home ----
/// Headlines for the home page's news panel, one paced Google News query per
/// followed artist. Serial on purpose: the pacer inside `artist_news` is the
/// budget, and nothing user-blocking waits on this call — the frontend
/// requests it after the page is already up.
#[tauri::command]
pub async fn home_artist_news(
state: State<'_, LibraryState>,
artists: Vec<String>,
) -> Result<std::collections::HashMap<String, Vec<metadata::ArtistNewsItem>>, String> {
let mut out = std::collections::HashMap::new();
for name in artists {
let items = state.metadata.artist_news(&name).await;
out.insert(name, items);
}
Ok(out)
}
/// Warm every cache the home page reads, in the background.
///
/// Measured 2026-08-04: assembling the release feed cold costs one
/// catalog_artist call per follow — 48 seconds for nine artists — which is why
/// the page itself must only ever read caches. This walks the follows through
/// the same metadata entry points the pages use (so the 15-minute catalog and
/// 24-hour identity caches fill), then tells the frontend fresh data is there.
#[tauri::command]
pub async fn home_warm(app: tauri::AppHandle, lib: State<'_, LibraryState>) -> Result<(), String> {
let follows = {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.followed_artists().map_err(|e| e.to_string()))
.await
.map_err(|e| e.to_string())??
};
let metadata = lib.metadata.clone();
tauri::async_runtime::spawn(async move {
for f in follows {
// Page payload first (releases), then the MB supplement the feed
// merges (A ? of WHEN exists only there), then the related pool.
let _ = metadata.catalog_artist(f.artist_id).await;
let _ = metadata
.catalog_artist_identity(f.artist_id, &f.name)
.await;
let _ = metadata.catalog_related(f.artist_id).await;
// One artist at a time and an emit per artist: the page fills in
// progressively instead of all-at-once after the last follow.
let _ = app.emit("soulseek:home-warmed", f.artist_id);
}
let _ = app.emit("soulseek:home-ready", ());
});
Ok(())
}
// ---- playlists live in src-tauri/src/playlists.rs ----
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// Compute (or fetch cached) waveform peaks for a local audio file.
/// Returns `buckets` normalized 0..1 values, or an empty array on failure.
#[tauri::command]
pub async fn waveform(
players: State<'_, PlayerState>,
path: String,
buckets: usize,
start_secs: Option<f64>,
end_secs: Option<f64>,
) -> Result<Vec<f32>, String> {
const MAX_WAVEFORM_BUCKETS: usize = 4096;
if buckets > MAX_WAVEFORM_BUCKETS {
return Err(format!(
"waveform bucket count exceeds {MAX_WAVEFORM_BUCKETS}"
));
}
if buckets == 0 {
return Ok(Vec::new());
}
// A range = a setlist SLICE's lane; each slice of one file caches
// separately under its own key.
let ranged = start_secs.is_some() || end_secs.is_some();
let key = if ranged {
format!(
"{path}#{buckets}@{:.3}-{}",
start_secs.unwrap_or(0.0),
end_secs
.map(|e| format!("{e:.3}"))
.unwrap_or_else(|| "end".into()),
)
} else {
format!("{path}#{buckets}")
};
let job = players.waveform_job(&key);
let result = async {
let job_guard = job.clone().lock_owned().await;
if let Some(cached) = players.cached_waveform(&key) {
return Ok(cached);
}
let p = std::path::PathBuf::from(&path);
let (job_guard, peaks) = tauri::async_runtime::spawn_blocking(move || {
let peaks = if ranged {
player::compute_waveform_range(&p, buckets, start_secs.unwrap_or(0.0), end_secs)
} else {
player::compute_waveform(&p, buckets)
};
(job_guard, peaks)
})
.await
.map_err(|e| e.to_string())?;
if !peaks.is_empty() {
players.remove_progressive_waveform(&key);
players.cache_waveform(key.clone(), peaks.clone());
}
drop(job_guard);
Ok(peaks)
}
.await;
players.release_waveform_job(&key, &job);
result
}
/// What the audio thread is doing right now.
///
/// The webview learns about playback only from events, so a RELOAD leaves it
/// believing nothing is playing while audio carries on — the scrubber moved
/// under the words "Nothing playing". This is the boot-time answer, the same
/// shape of recovery `app_status` already provides for the Soulseek connection.
#[tauri::command]
pub fn player_state(players: State<'_, PlayerState>) -> crate::playback::PlaybackSnapshot {
players
.last_transport
.lock()
.map(|snapshot| snapshot.clone())
.unwrap_or_default()
}
/// Player transport: action ∈ pause | resume | stop, or seek/volume with value.
#[tauri::command]
pub fn player_control(
app: tauri::AppHandle,
players: State<'_, PlayerState>,
action: String,
value: Option<f64>,
rate: Option<f64>,
) -> Result<(), String> {
match action.as_str() {
"pause" => players.player.pause(),
"resume" => players.player.resume(),
"stop" => {
// Before the player, so no analyzer is still holding a start
// that could fire behind the stop.
crate::loudness::on_playback_stopped(&app);
players.player.stop()
}
"seek" => players.player.seek(value.unwrap_or(0.0)),
"scratch" => players
.player
.scratch(value.unwrap_or(0.0), rate.unwrap_or(1.0) as f32),
"end-scratch" => players.player.end_scratch(value.unwrap_or(0.0)),
"volume" => players.player.set_volume(value.unwrap_or(0.7) as f32),
other => return Err(format!("unknown action {other}")),
}
Ok(())
}
/// Enter mini mode: show the pre-built mini window (declared in
/// tauri.conf.json, created hidden at startup — runtime window creation
/// from a command half-initialises on Windows) and hide the main one. The
/// main window is HIDDEN, never closed: its frontend stays alive as the one
/// brain the mini remote-controls (see src/lib/miniBridge.svelte.js).
#[tauri::command]
pub async fn mini_open(app: tauri::AppHandle, width: f64, height: f64) -> Result<(), String> {
let mini = app
.get_webview_window("mini")
.ok_or_else(|| "mini window missing from config".to_string())?;
let _ = mini.set_size(tauri::LogicalSize::new(width, height));
mini.show().map_err(|e| e.to_string())?;
let _ = mini.set_focus();
if let Some(main) = app.get_webview_window("main") {
let _ = main.hide();
}
Ok(())
}
/// Leave mini mode: bring the full app back and tuck the mini away warm
/// (hidden, not closed — reopening is instant and its mirror stays hot).
#[tauri::command]
pub async fn mini_close(app: tauri::AppHandle) -> Result<(), String> {
if let Some(main) = app.get_webview_window("main") {
let _ = main.show();
let _ = main.set_focus();
}
if let Some(mini) = app.get_webview_window("mini") {
let _ = mini.hide();
}
Ok(())
}
/// The OS half of dark-glass mode: acrylic blur behind the calling window
/// (the window itself is transparent; the CSS half — :root.glass — thins the
/// window canvas so the blur shows through).
///
/// NOT tauri's Effect::Acrylic: on Win11 22H2+ that picks the DWMSBT system
/// backdrop, which Windows SUSPENDS whenever the maximized webview gets
/// promoted to a hardware overlay — every hover animation or compositor
/// scroll blinked the glass dark for a beat (Kyle, 2026-08-26). The legacy
/// accent-policy acrylic below is composed with the window itself and holds
/// through interaction.
/// Whether dark-glass is currently ON for the MAIN window. Written by
/// window_glass, read by the window subclass in thumbbar.rs so it can
/// re-assert the accent after the gestures where Windows drops it: the
/// modal move/size loop, and a maximize/restore (Kyle photographed the
/// desktop punching through a maximized window on 2026-08-28 — the accent
/// did not survive the transition on its own).
pub(crate) static GLASS_ON: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// The accent states this window uses. `Blur` is the pre-acrylic
/// ACCENT_ENABLE_BLURBEHIND: visually close, but composited by DWM through
/// the modal move/size loop where the acrylic is SUSPENDED — the subclass
/// swaps down to it for the length of a gesture and back up on release
/// (see thumbbar.rs). Same struct, one field different.
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum Accent {
Off,
Blur,
Acrylic,
}
pub(crate) fn apply_accent(hwnd: *mut std::ffi::c_void, accent: Accent) -> Result<(), String> {
#[repr(C)]
struct AccentPolicy {
state: u32,
flags: u32,
gradient: u32, // 0xAABBGGRR
animation: u32,
}
#[repr(C)]
struct AttribData {
attrib: u32,
data: *mut std::ffi::c_void,
size: usize,
}
#[allow(non_snake_case)]
#[link(name = "kernel32")]
extern "system" {
fn GetModuleHandleA(name: *const u8) -> *mut std::ffi::c_void;
fn GetProcAddress(
module: *mut std::ffi::c_void,
name: *const u8,
) -> *mut std::ffi::c_void;
}
type Swca = unsafe extern "system" fn(*mut std::ffi::c_void, *mut AttribData) -> i32;
unsafe {
let user32 = GetModuleHandleA(b"user32.dll\0".as_ptr());
let f = if user32.is_null() {
std::ptr::null_mut()
} else {
GetProcAddress(user32, b"SetWindowCompositionAttribute\0".as_ptr())
};
if f.is_null() {
return Err("SetWindowCompositionAttribute unavailable".into());
}
let f: Swca = std::mem::transmute(f);
let mut policy = AccentPolicy {
state: match accent {
Accent::Off => 0, // ACCENT_DISABLED
Accent::Blur => 3, // ACCENT_ENABLE_BLURBEHIND
Accent::Acrylic => 4, // ACCENT_ENABLE_ACRYLICBLURBEHIND
},
flags: 0,
// A whisper of black in the OS layer (acrylic treats alpha 0 as
// "don't draw"); the real darkening is the CSS canvas tint.
gradient: if matches!(accent, Accent::Off) { 0 } else { 0x2E00_0000 },
animation: 0,
};
let mut data = AttribData {
attrib: 0x13, // WCA_ACCENT_POLICY
data: &mut policy as *mut _ as _,
size: std::mem::size_of::<AccentPolicy>(),
};
f(hwnd, &mut data);
}
Ok(())
}
/// The DWM maximize/restore zoom animation. The accent visual detaches for
/// its length and takes until ~650ms after the call to recompose (measured
/// 2026-08-28 at 43ms/frame: desktop icons legible straight through the
/// maximized window). Disabling it removed the punch-through outright, but
/// also the animation — the window snapped to size — and Kyle chose the
/// animation back with the canvas grounded across the recomposition instead
/// (2026-08-29). So transitions are now explicitly forced ON (clearing any
/// disable a previous build left on this window), and the discrete bracket
/// in thumbbar.rs + GROUND_DISCRETE in glass.js covers the detach window.
fn set_transitions_disabled(hwnd: *mut std::ffi::c_void, disabled: bool) {
#[allow(non_snake_case)]
#[link(name = "kernel32")]
extern "system" {
fn LoadLibraryA(name: *const u8) -> *mut std::ffi::c_void;
fn GetProcAddress(
module: *mut std::ffi::c_void,
name: *const u8,
) -> *mut std::ffi::c_void;
}
type DwmSet =
unsafe extern "system" fn(*mut std::ffi::c_void, u32, *const std::ffi::c_void, u32) -> i32;
unsafe {
let dwm = LoadLibraryA(b"dwmapi.dll".as_ptr());
if dwm.is_null() {
return;
}
let f = GetProcAddress(dwm, b"DwmSetWindowAttribute".as_ptr());
if f.is_null() {
return;
}
let f: DwmSet = std::mem::transmute(f);
let val: i32 = if disabled { 1 } else { 0 };
// 3 = DWMWA_TRANSITIONS_FORCEDISABLED
let _ = f(hwnd, 3, &val as *const _ as _, 4);
}
}
#[tauri::command]
pub fn window_glass(window: tauri::WebviewWindow, on: bool) -> Result<(), String> {
// Clear any system backdrop a previous toggle/build may have applied.
let _ = window.set_effects(None::<tauri::utils::config::WindowEffectsConfig>);
let hwnd = window.hwnd().map_err(|e| e.to_string())?.0 as *mut std::ffi::c_void;
// The mini calls this too (window_glass acts on the CALLING window), and
// the subclass re-asserting from thumbbar.rs is on the MAIN window only —
// so only the main window's state is tracked.
if window.label() == "main" {
GLASS_ON.store(on, std::sync::atomic::Ordering::Relaxed);
}
set_transitions_disabled(hwnd, false);
apply_accent(hwnd, if on { Accent::Acrylic } else { Accent::Off })
}
#[cfg(test)]
mod hero_processing_tests {
use super::*;
#[test]
fn baked_look_is_deterministic_and_changes_pixels() {
let original = image::RgbImage::from_pixel(32, 18, image::Rgb([92, 126, 161]));
let mut first = original.clone();
let mut second = original.clone();
let source = b"stable provider image bytes";
apply_baked_hero_look(&mut first, source);
apply_baked_hero_look(&mut second, source);
assert_eq!(first, second);
assert_ne!(first, original);
}
#[test]
fn sixteen_by_nine_source_finishes_at_uhd() {
assert_eq!(scaled_height(1280, 720, 3840), 2160);
}
#[test]
fn letterbox_bars_are_trimmed() {
use image::GenericImageView as _;
// 256x144 mid-gray photo with 20px black bars baked top and bottom.
let mut img = image::RgbImage::from_pixel(256, 144, image::Rgb([120, 110, 100]));
for y in 0..20 {
for x in 0..256 {
img.put_pixel(x, y, image::Rgb([2, 2, 2]));
img.put_pixel(x, 143 - y, image::Rgb([2, 2, 2]));
}
}
let trimmed =
trim_letterbox_bars(&image::DynamicImage::ImageRgb8(img)).expect("bars should trim");
assert_eq!(trimmed.dimensions(), (256, 104));
}
#[test]
fn dark_photo_is_not_eaten() {
// Uniformly dark-but-not-black image: everything is "barlike", so the
// half-image guard rejects the crop and nothing is trimmed.
let img = image::RgbImage::from_pixel(256, 144, image::Rgb([24, 20, 18]));
assert!(trim_letterbox_bars(&image::DynamicImage::ImageRgb8(img)).is_none());
}
#[test]
fn every_hero_gets_a_maximal_stable_crop() {
// Square sources retain every horizontal pixel; only Y can move.
let square = maximal_hero_rect(1000, 1000).expect("square must crop");
assert_eq!((square.x, square.width, square.height), (0, 1000, 562));
// Wide strips retain full height and remain horizontally centered.
let strip = maximal_hero_rect(1500, 500).expect("strip must crop");
assert_eq!(
(strip.x, strip.y, strip.width, strip.height),
(306, 0, 888, 500)
);
// Already 16:9 is the full source rectangle.
let hd = maximal_hero_rect(1920, 1080).expect("HD must plan");
assert_eq!((hd.x, hd.y, hd.width, hd.height), (0, 0, 1920, 1080));
}
#[test]
fn featureless_murk_band_is_trimmed() {
// Bright photo with a dark featureless gradient band at the top —
// YouTube composites these between the black bar and the artwork.
let mut img = image::RgbImage::from_pixel(256, 144, image::Rgb([150, 140, 130]));
for y in 0..24 {
let shade = 20 + (y as u8); // 20..43 — dark, low range per row
for x in 0..256 {
img.put_pixel(x, y, image::Rgb([shade, shade, shade]));
}
}
let trimmed = trim_letterbox_bars(&image::DynamicImage::ImageRgb8(img))
.expect("murk band should trim");
use image::GenericImageView as _;
assert_eq!(trimmed.dimensions(), (256, 120));
}
}