5513 lines
209 KiB
Rust
5513 lines
209 KiB
Rust
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 |