forked from Keelrleek/SoulStream
The previous .git directory was found gutted (only info/ remained), so history is unrecoverable locally. This commit restores version control with the tree exactly as it stands: the working app, the preview-tracks launcher on the artist hero, the scrollable-track-previews design bundle (docs + approved mockups), and the Phase 0 validation artifacts.
3894 lines
141 KiB
Rust
3894 lines
141 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,
|
||
}
|
||
|
||
#[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,
|
||
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>,
|
||
) -> Result<(), String> {
|
||
let mut cfg = config::load(&app);
|
||
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;
|
||
}
|
||
config::save(&app, &cfg);
|
||
|
||
// 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 }),
|
||
);
|
||
|
||
*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
|
||
);
|
||
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 { .. }) {
|
||
// 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 {
|
||
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),
|
||
"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 }),
|
||
);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
});
|
||
|
||
Ok(greeting)
|
||
}
|
||
|
||
// ---- 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.
|
||
const ARTIST_ART_MANIFEST_RECIPE: &str = "artist-art-tier-v4-wide-only";
|
||
const ARTIST_ART_MANIFEST_TTL: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
|
||
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")))
|
||
}
|
||
|
||
async fn load_artist_art_manifest(
|
||
app: &tauri::AppHandle,
|
||
artist_id: u64,
|
||
) -> Option<metadata::CatalogArtistArt> {
|
||
let path = artist_art_manifest_path(app, artist_id).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 < ARTIST_ART_MANIFEST_TTL);
|
||
if !fresh || metadata.len() > ARTIST_ART_MANIFEST_MAX_BYTES {
|
||
let _ = tokio::fs::remove_file(path).await;
|
||
return None;
|
||
}
|
||
let bytes = tokio::fs::read(&path).await.ok()?;
|
||
let manifest = serde_json::from_slice::<CachedArtistArtManifest>(&bytes).ok()?;
|
||
let art = manifest.art;
|
||
let valid_urls = art
|
||
.banners
|
||
.iter()
|
||
.chain(art.logos.iter())
|
||
.all(|url| url.starts_with("https://"));
|
||
(manifest.recipe == ARTIST_ART_MANIFEST_RECIPE
|
||
&& manifest.artist_id == artist_id
|
||
&& art.artist.id == artist_id
|
||
&& !art.banner_custom
|
||
&& art.banners.len() <= 8
|
||
&& art.logos.len() <= 8
|
||
&& valid_urls)
|
||
.then_some(art)
|
||
}
|
||
|
||
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;
|
||
|
||
fn artist_hero_dir(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
|
||
let dir = app
|
||
.path()
|
||
.app_data_dir()
|
||
.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_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 mut art = if let Some(cached) = load_artist_art_manifest(&app, artist_id).await {
|
||
cached
|
||
} else {
|
||
let resolved = state
|
||
.metadata
|
||
.catalog_artist_art(artist_id)
|
||
.await
|
||
.ok_or_else(|| "artist artwork lookup failed".to_string())?;
|
||
let store_app = app.clone();
|
||
let store_art = resolved.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}");
|
||
}
|
||
});
|
||
resolved
|
||
};
|
||
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)
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
const COVER_LOGO_RECIPE: &str = "cover-logo-perf-v3-identity";
|
||
const COVER_LOGO_MAX_COVERS: usize = 14;
|
||
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;
|
||
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 _};
|
||
let mut best: Option<(f32, usize, String, image::RgbaImage)> = 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)
|
||
})
|
||
.or_else(|| {
|
||
image::load_from_memory(candidate.bytes_1000.as_slice())
|
||
.ok()
|
||
.and_then(|image| {
|
||
crate::cover_logo::extract_wordmark(&image, &candidate.loc)
|
||
})
|
||
});
|
||
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.rank, candidate.title, mark.image));
|
||
}
|
||
}
|
||
let Some((_, rank, title, image)) = best 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 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>,
|
||
) -> Result<Vec<CoverLogoDiag>, String> {
|
||
use image::GenericImageView as _;
|
||
|
||
// Download 1000px covers for OCR.
|
||
let mut downloaded = Vec::new();
|
||
for cover in covers.into_iter().take(14) {
|
||
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,
|
||
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) {
|
||
if let Some(mark) = crate::cover_logo::extract_wordmark(&img_ex, &loc_ex) {
|
||
row.mark_w = mark.image.width();
|
||
row.mark_h = mark.image.height();
|
||
row.extract_score = mark.score;
|
||
}
|
||
}
|
||
}
|
||
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_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 {
|
||
let cached = crate::playback::cache_path(&app, &f.username, &f.path).filter(|c| c.exists());
|
||
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))
|
||
}
|
||
|
||
/// 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);
|
||
players.player.play_local(&cache, 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);
|
||
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());
|
||
players
|
||
.player
|
||
.play_stream(&partial, progress, 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());
|
||
|
||
players
|
||
.player
|
||
.play_stream(&partial, progress, 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 {
|
||
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);
|
||
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<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());
|
||
}
|
||
let Some(cache) = playback::cache_path(&app, &username, &path) else {
|
||
return Ok(Vec::new());
|
||
};
|
||
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(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(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(Vec::new());
|
||
}
|
||
if let Some(previous) = players.progressive_waveform(&key) {
|
||
if !previous.should_recompute(have) {
|
||
return Ok(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, mut 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() {
|
||
peaks.resize(buckets, 0.0);
|
||
players.cache_progressive_waveform(
|
||
key.clone(),
|
||
playback::ProgressiveWaveform::new(have, size, peaks.clone()),
|
||
);
|
||
}
|
||
drop(job_guard);
|
||
Ok(peaks)
|
||
}
|
||
.await;
|
||
players.release_waveform_job(&key, &job);
|
||
result
|
||
}
|
||
|
||
/// Play an already-downloaded local file.
|
||
#[tauri::command]
|
||
pub fn play_local(
|
||
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.play_local(&path, duration);
|
||
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(
|
||
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);
|
||
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())?
|
||
}
|
||
|
||
#[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).
|
||
#[tauri::command]
|
||
pub async fn album_art(path: String) -> Result<String, String> {
|
||
let bytes = tauri::async_runtime::spawn_blocking(move || std::fs::read(path))
|
||
.await
|
||
.map_err(|e| e.to_string())?
|
||
.map_err(|e| e.to_string())?;
|
||
use base64::Engine as _;
|
||
Ok(format!(
|
||
"data:image/jpeg;base64,{}",
|
||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||
))
|
||
}
|
||
|
||
/// 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]
|
||
pub async fn record_play(
|
||
lib: State<'_, LibraryState>,
|
||
artist: String,
|
||
title: String,
|
||
) -> Result<i64, String> {
|
||
let l = lib.library.clone();
|
||
tauri::async_runtime::spawn_blocking(move || {
|
||
l.record_play(&artist, &title, now_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())?
|
||
}
|
||
|
||
// ---- playlists ----
|
||
|
||
fn now_secs() -> i64 {
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_secs() as i64)
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn list_playlists(
|
||
lib: State<'_, LibraryState>,
|
||
) -> Result<Vec<library::Playlist>, String> {
|
||
let l = lib.library.clone();
|
||
tauri::async_runtime::spawn_blocking(move || l.list_playlists().map_err(|e| e.to_string()))
|
||
.await
|
||
.map_err(|e| e.to_string())?
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn create_playlist(
|
||
app: tauri::AppHandle,
|
||
lib: State<'_, LibraryState>,
|
||
name: String,
|
||
) -> Result<i64, String> {
|
||
let l = lib.library.clone();
|
||
let id = tauri::async_runtime::spawn_blocking(move || l.create_playlist(&name, now_secs()))
|
||
.await
|
||
.map_err(|e| e.to_string())?
|
||
.map_err(|e| e.to_string())?;
|
||
let _ = app.emit("soulseek:playlists-changed", ());
|
||
Ok(id)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn delete_playlist(
|
||
app: tauri::AppHandle,
|
||
lib: State<'_, LibraryState>,
|
||
id: i64,
|
||
) -> Result<(), String> {
|
||
let l = lib.library.clone();
|
||
tauri::async_runtime::spawn_blocking(move || l.delete_playlist(id))
|
||
.await
|
||
.map_err(|e| e.to_string())?
|
||
.map_err(|e| e.to_string())?;
|
||
let _ = app.emit("soulseek:playlists-changed", ());
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn playlist_tracks(
|
||
lib: State<'_, LibraryState>,
|
||
id: i64,
|
||
) -> Result<Vec<library::PlaylistTrack>, String> {
|
||
let l = lib.library.clone();
|
||
tauri::async_runtime::spawn_blocking(move || l.playlist_tracks(id).map_err(|e| e.to_string()))
|
||
.await
|
||
.map_err(|e| e.to_string())?
|
||
}
|
||
|
||
#[derive(serde::Deserialize)]
|
||
pub struct TrackInput {
|
||
pub title: String,
|
||
pub artist: String,
|
||
pub duration_secs: Option<u32>,
|
||
pub path: String,
|
||
pub art_path: Option<String>,
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn add_to_playlist(
|
||
app: tauri::AppHandle,
|
||
lib: State<'_, LibraryState>,
|
||
playlist_id: i64,
|
||
tracks: Vec<TrackInput>,
|
||
) -> Result<(), String> {
|
||
let l = lib.library.clone();
|
||
tauri::async_runtime::spawn_blocking(move || -> library::Result<()> {
|
||
for t in tracks {
|
||
l.add_to_playlist(
|
||
playlist_id,
|
||
&library::PlaylistTrack {
|
||
position: 0, // assigned by insert
|
||
title: t.title,
|
||
artist: t.artist,
|
||
duration_secs: t.duration_secs,
|
||
path: t.path,
|
||
art_path: t.art_path,
|
||
},
|
||
)?;
|
||
}
|
||
Ok(())
|
||
})
|
||
.await
|
||
.map_err(|e| e.to_string())?
|
||
.map_err(|e| e.to_string())?;
|
||
let _ = app.emit("soulseek:playlists-changed", ());
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn remove_from_playlist(
|
||
lib: State<'_, LibraryState>,
|
||
playlist_id: i64,
|
||
position: i64,
|
||
) -> Result<(), String> {
|
||
let l = lib.library.clone();
|
||
tauri::async_runtime::spawn_blocking(move || l.remove_from_playlist(playlist_id, position))
|
||
.await
|
||
.map_err(|e| e.to_string())?
|
||
.map_err(|e| e.to_string())?;
|
||
Ok(())
|
||
}
|
||
|
||
/// 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,
|
||
) -> 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());
|
||
}
|
||
let key = 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 = 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
|
||
}
|
||
|
||
/// Player transport: action ∈ pause | resume | stop, or seek/volume with value.
|
||
#[tauri::command]
|
||
pub fn player_control(
|
||
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" => 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(())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod hero_processing_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn baked_look_is_deterministic_and_changes_pixels() {
|
||
let original = image::RgbImage::from_pixel(32, 18, image::Rgb([92, 126, 161]));
|
||
let mut first = original.clone();
|
||
let mut second = original.clone();
|
||
let source = b"stable provider image bytes";
|
||
|
||
apply_baked_hero_look(&mut first, source);
|
||
apply_baked_hero_look(&mut second, source);
|
||
|
||
assert_eq!(first, second);
|
||
assert_ne!(first, original);
|
||
}
|
||
|
||
#[test]
|
||
fn sixteen_by_nine_source_finishes_at_uhd() {
|
||
assert_eq!(scaled_height(1280, 720, 3840), 2160);
|
||
}
|
||
|
||
#[test]
|
||
fn letterbox_bars_are_trimmed() {
|
||
use image::GenericImageView as _;
|
||
// 256x144 mid-gray photo with 20px black bars baked top and bottom.
|
||
let mut img = image::RgbImage::from_pixel(256, 144, image::Rgb([120, 110, 100]));
|
||
for y in 0..20 {
|
||
for x in 0..256 {
|
||
img.put_pixel(x, y, image::Rgb([2, 2, 2]));
|
||
img.put_pixel(x, 143 - y, image::Rgb([2, 2, 2]));
|
||
}
|
||
}
|
||
let trimmed =
|
||
trim_letterbox_bars(&image::DynamicImage::ImageRgb8(img)).expect("bars should trim");
|
||
assert_eq!(trimmed.dimensions(), (256, 104));
|
||
}
|
||
|
||
#[test]
|
||
fn dark_photo_is_not_eaten() {
|
||
// Uniformly dark-but-not-black image: everything is "barlike", so the
|
||
// half-image guard rejects the crop and nothing is trimmed.
|
||
let img = image::RgbImage::from_pixel(256, 144, image::Rgb([24, 20, 18]));
|
||
assert!(trim_letterbox_bars(&image::DynamicImage::ImageRgb8(img)).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn every_hero_gets_a_maximal_stable_crop() {
|
||
// Square sources retain every horizontal pixel; only Y can move.
|
||
let square = maximal_hero_rect(1000, 1000).expect("square must crop");
|
||
assert_eq!((square.x, square.width, square.height), (0, 1000, 562));
|
||
// Wide strips retain full height and remain horizontally centered.
|
||
let strip = maximal_hero_rect(1500, 500).expect("strip must crop");
|
||
assert_eq!(
|
||
(strip.x, strip.y, strip.width, strip.height),
|
||
(306, 0, 888, 500)
|
||
);
|
||
// Already 16:9 is the full source rectangle.
|
||
let hd = maximal_hero_rect(1920, 1080).expect("HD must plan");
|
||
assert_eq!((hd.x, hd.y, hd.width, hd.height), (0, 0, 1920, 1080));
|
||
}
|
||
|
||
#[test]
|
||
fn featureless_murk_band_is_trimmed() {
|
||
// Bright photo with a dark featureless gradient band at the top —
|
||
// YouTube composites these between the black bar and the artwork.
|
||
let mut img = image::RgbImage::from_pixel(256, 144, image::Rgb([150, 140, 130]));
|
||
for y in 0..24 {
|
||
let shade = 20 + (y as u8); // 20..43 — dark, low range per row
|
||
for x in 0..256 {
|
||
img.put_pixel(x, y, image::Rgb([shade, shade, shade]));
|
||
}
|
||
}
|
||
let trimmed = trim_letterbox_bars(&image::DynamicImage::ImageRgb8(img))
|
||
.expect("murk band should trim");
|
||
use image::GenericImageView as _;
|
||
assert_eq!(trimmed.dimensions(), (256, 120));
|
||
}
|
||
}
|