An artist's hero was re-discovered every 24 hours through a tier that fails per-name, and the stale manifest was deleted on the way in -- before the replacement lookup had run. So one bad discovery day did not merely fail to refresh a hero, it destroyed the good one first and left the artist blank. On this machine that took the persisted store from 26 artists down to 3; Crazy P, MSPAINT, Sword II, Hermeth, Billy Cobb, Pond, Weezer, Daft Punk and King Krule all had correct banners on disk that nothing would ever read again. Three changes: - The manifest TTL goes from 24 hours to 30 days, matching the cover resolution horizon. A press photo is still the artist's press photo a month later; it does not go stale the way metadata does. - Reading a manifest no longer destroys it. Stale and older-recipe manifests are retained as a floor and only replaced once a lookup returns something better, which also migrates them forward to the current recipe. Only files that are unusable -- oversized, unparseable, or belonging to another artist -- are removed. - A failed hero discovery is detected by an EMPTY BANNER LIST, not a None: catalog_artist_art returns Some as soon as the Deezer profile resolves, so the previous None check could never fire, and an empty result would also overwrite good banners whenever logos were present. Also widens discovery so fewer artists need the floor at all. The query ladder gains the bare artist name: decorated queries can collapse a rare artist onto a popular entity sharing a word -- every "Hana Vu ... photo" phrasing returns the Japanese group HANA (525 of 525 cards, quoting included), while the bare name returns her real press photos with 16 of 17 passing the identity gate as Strong. The gate, not the query, is what filters. And a near-empty Bing page now counts as degraded. The retry test was is_empty(), which let the commonest degraded shape through: a one-card page of something unrelated, cached as that artist's verdict for a quarter hour. Measured 15 of 30 waves for MSPAINT and Billy Cobb. The richer of the two reads is kept so a thin retry cannot lose a good first answer. Verified live: Crazy P, MSPAINT, Sword II and Hermeth all render heroes again.
4049 lines
147 KiB
Rust
4049 lines
147 KiB
Rust
use serde::{Deserialize, Serialize};
|
||
use tauri::{Emitter, Manager, State};
|
||
|
||
use soulseek_client::{Client, Config, DownloadState, DownloadUpdate, Event};
|
||
use soulseek_proto::types::SearchResponse;
|
||
|
||
use crate::config;
|
||
use crate::hero_crop::{maximal_hero_rect, plan_hero_crop, HeroCropPlan};
|
||
use crate::hero_detector::detect_subjects;
|
||
use crate::pipeline::{self, LibraryState};
|
||
use crate::playback::{self, PlayerState};
|
||
use crate::state::AppState;
|
||
|
||
#[derive(Serialize)]
|
||
pub struct AppStatus {
|
||
pub version: &'static str,
|
||
pub connected_as: Option<String>,
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn app_status(state: State<'_, AppState>) -> Result<AppStatus, String> {
|
||
Ok(AppStatus {
|
||
version: env!("CARGO_PKG_VERSION"),
|
||
connected_as: state.connected_as.lock().await.clone(),
|
||
})
|
||
}
|
||
|
||
/// The server's excluded-phrase list for query sanitizing; empty until the
|
||
/// server sends it after login. Frontend also receives it via the
|
||
/// `soulseek:excluded-phrases` event — this command covers a WebView reload
|
||
/// that happens after login already delivered the event.
|
||
#[tauri::command]
|
||
pub async fn excluded_search_phrases(state: State<'_, AppState>) -> Result<Vec<String>, String> {
|
||
Ok(state.excluded_search_phrases.lock().await.clone())
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct StoredCredentials {
|
||
pub username: String,
|
||
pub has_password: bool,
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn get_credentials(app: tauri::AppHandle) -> StoredCredentials {
|
||
let cfg = config::load(&app);
|
||
let has_password = config::has_password(&cfg.username);
|
||
StoredCredentials {
|
||
username: cfg.username,
|
||
has_password,
|
||
}
|
||
}
|
||
|
||
/// Settings surfaced to the Settings view.
|
||
#[derive(Serialize)]
|
||
pub struct Settings {
|
||
pub username: String,
|
||
pub has_password: bool,
|
||
pub download_dir: String,
|
||
pub library_layout: String,
|
||
pub shared_folders: Vec<String>,
|
||
pub listen_port: Option<u16>,
|
||
pub upload_slots: u32,
|
||
pub connected: bool,
|
||
pub share_files: u32,
|
||
pub audio_output_device: Option<String>,
|
||
pub audio_output_devices: Vec<String>,
|
||
pub artist_hero_upscaler: config::ArtistHeroUpscaler,
|
||
pub replace_embedded_art: bool,
|
||
/// 0 = embed the original at full resolution.
|
||
pub embed_art_max_edge: u32,
|
||
pub write_folder_cover: bool,
|
||
pub stream_keep_forever: bool,
|
||
pub stream_retention_days: u32,
|
||
/// 0 = unlimited.
|
||
pub stream_cache_max_mb: u64,
|
||
pub stream_source_prefs: config::SourcePrefs,
|
||
pub download_source_prefs: config::SourcePrefs,
|
||
pub volume_normalization: bool,
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn get_settings(
|
||
app: tauri::AppHandle,
|
||
state: State<'_, AppState>,
|
||
) -> Result<Settings, String> {
|
||
let cfg = config::load(&app);
|
||
let share_files = state
|
||
.client
|
||
.lock()
|
||
.await
|
||
.as_ref()
|
||
.map(|c| c.share_counts().1)
|
||
.unwrap_or(0);
|
||
Ok(Settings {
|
||
has_password: config::has_password(&cfg.username),
|
||
download_dir: config::download_dir(&app, &cfg)
|
||
.to_string_lossy()
|
||
.into_owned(),
|
||
library_layout: config::library_layout(&cfg).to_string(),
|
||
shared_folders: cfg.shared_folders.clone(),
|
||
listen_port: cfg.listen_port,
|
||
upload_slots: cfg.upload_slots.unwrap_or(2),
|
||
connected: state.connected_as.lock().await.is_some(),
|
||
share_files,
|
||
audio_output_device: cfg.audio_output_device.clone(),
|
||
audio_output_devices: player::output_device_names().unwrap_or_default(),
|
||
artist_hero_upscaler: cfg.artist_hero_upscaler,
|
||
replace_embedded_art: config::replace_embedded_art(&cfg),
|
||
embed_art_max_edge: config::embed_art_max_edge(&cfg),
|
||
write_folder_cover: config::write_folder_cover(&cfg),
|
||
stream_keep_forever: config::stream_keep_forever(&cfg),
|
||
stream_retention_days: config::stream_retention_days(&cfg),
|
||
stream_cache_max_mb: config::stream_cache_max_mb(&cfg),
|
||
stream_source_prefs: cfg.stream_source_prefs,
|
||
download_source_prefs: cfg.download_source_prefs,
|
||
volume_normalization: config::volume_normalization(&cfg),
|
||
username: cfg.username,
|
||
})
|
||
}
|
||
|
||
/// Persist non-secret settings. Some (download dir, port) apply on next
|
||
/// connect; shared folders re-index live if connected.
|
||
#[tauri::command]
|
||
pub async fn set_settings(
|
||
app: tauri::AppHandle,
|
||
state: State<'_, AppState>,
|
||
players: State<'_, PlayerState>,
|
||
download_dir: Option<String>,
|
||
library_layout: Option<String>,
|
||
shared_folders: Option<Vec<String>>,
|
||
listen_port: Option<u16>,
|
||
upload_slots: Option<u32>,
|
||
audio_output_device: Option<String>,
|
||
artist_hero_upscaler: Option<config::ArtistHeroUpscaler>,
|
||
replace_embedded_art: Option<bool>,
|
||
embed_art_max_edge: Option<u32>,
|
||
write_folder_cover: Option<bool>,
|
||
stream_keep_forever: Option<bool>,
|
||
stream_retention_days: Option<u32>,
|
||
stream_cache_max_mb: Option<u64>,
|
||
stream_source_prefs: Option<config::SourcePrefs>,
|
||
download_source_prefs: Option<config::SourcePrefs>,
|
||
volume_normalization: Option<bool>,
|
||
) -> 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;
|
||
}
|
||
if let Some(enabled) = volume_normalization {
|
||
cfg.volume_normalization = Some(enabled);
|
||
// Applies live: off glides the current track back to unity; on
|
||
// takes effect from the next track.
|
||
crate::loudness::set_enabled(&app, enabled);
|
||
}
|
||
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 { .. }) {
|
||
// Gapless normalization: measure the next
|
||
// track now so its gain is ready at the
|
||
// boundary.
|
||
if let Some(c) = &cache {
|
||
crate::loudness::on_preload_ready(&event_app, c);
|
||
}
|
||
// Only explicit saves reach the library —
|
||
// unless streamed tracks are configured to
|
||
// be kept forever.
|
||
let keep =
|
||
config::stream_keep_forever(&config::load(&event_app));
|
||
let save = if keep {
|
||
save_cache.or(cache)
|
||
} else {
|
||
save_cache
|
||
};
|
||
let app2 = event_app.clone();
|
||
let username = update.username.clone();
|
||
let remote = update.filename.clone();
|
||
tauri::async_runtime::spawn(async move {
|
||
if let Some(cache) = save {
|
||
let downloads_dir =
|
||
config::download_dir(&app2, &config::load(&app2));
|
||
if let Err(err) = organize_cached_save(
|
||
&app2,
|
||
&downloads_dir,
|
||
&cache,
|
||
&remote,
|
||
)
|
||
.await
|
||
{
|
||
log::warn!("could not save completed preload {username}\\{remote}: {err}");
|
||
}
|
||
let _ = app2.emit("soulseek:library-changed", ());
|
||
}
|
||
// The cache just grew; enforce retention
|
||
// after any save copy is done with it.
|
||
let app3 = app2.clone();
|
||
let _ = tauri::async_runtime::spawn_blocking(move || {
|
||
crate::playback::prune_cache(&app3)
|
||
})
|
||
.await;
|
||
});
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
// Stream downloads feed the player instead of the
|
||
// Downloads view.
|
||
let stream = event_app
|
||
.try_state::<PlayerState>()
|
||
.and_then(|ps| ps.streams.lock().unwrap().get(&update.id).cloned());
|
||
if let Some(progress) = stream {
|
||
use soulseek_client::DownloadState as DS;
|
||
let (done, failed) = match &update.state {
|
||
DS::Complete { .. } => (true, None),
|
||
DS::Failed { reason } => (false, Some(reason.clone())),
|
||
DS::Cancelled => (false, Some("stream cancelled".to_string())),
|
||
_ => (false, None),
|
||
};
|
||
progress.update(update.received, done, failed.clone());
|
||
let _ = event_app.emit(
|
||
"soulseek:stream",
|
||
serde_json::json!({
|
||
"received": update.received,
|
||
"total": update.total,
|
||
"done": done,
|
||
"failed": failed,
|
||
}),
|
||
);
|
||
if done || failed.is_some() {
|
||
if let Some(ps) = event_app.try_state::<PlayerState>() {
|
||
ps.streams.lock().unwrap().remove(&update.id);
|
||
let cache = ps.stream_caches.lock().unwrap().remove(&update.id);
|
||
let save_cache =
|
||
ps.deferred_saves.lock().unwrap().remove(&update.id);
|
||
if done {
|
||
// Streams stay cache-only unless the user
|
||
// saved this track or retention is set to
|
||
// keep streamed tracks forever.
|
||
let keep =
|
||
config::stream_keep_forever(&config::load(&event_app));
|
||
let save = if keep {
|
||
save_cache.or(cache)
|
||
} else {
|
||
save_cache
|
||
};
|
||
let app2 = event_app.clone();
|
||
let username = update.username.clone();
|
||
let remote = update.filename.clone();
|
||
tauri::async_runtime::spawn(async move {
|
||
if let Some(cache) = save {
|
||
let downloads_dir =
|
||
config::download_dir(&app2, &config::load(&app2));
|
||
if let Err(err) = organize_cached_save(
|
||
&app2,
|
||
&downloads_dir,
|
||
&cache,
|
||
&remote,
|
||
)
|
||
.await
|
||
{
|
||
log::warn!("could not save completed stream {username}\\{remote}: {err}");
|
||
}
|
||
let _ = app2.emit("soulseek:library-changed", ());
|
||
}
|
||
// The cache just grew; enforce retention
|
||
// after any save copy is done with it.
|
||
let app3 = app2.clone();
|
||
let _ = tauri::async_runtime::spawn_blocking(move || {
|
||
crate::playback::prune_cache(&app3)
|
||
})
|
||
.await;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
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";
|
||
// A resolved hero is expensive to find and does not go stale the way metadata
|
||
// does -- a press photo is still the artist's press photo a month later. The
|
||
// old 24h expiry meant every artist was re-discovered daily through a tier that
|
||
// fails per-name, so a single bad day blanked artists that had perfectly good
|
||
// heroes: 26 manifests on disk (Crazy P, MSPAINT, Sword II, Hermeth, Billy Cobb,
|
||
// Pond, Weezer, Daft Punk...) went to 3. Match the cover-resolution horizon.
|
||
const ARTIST_ART_MANIFEST_TTL: std::time::Duration =
|
||
std::time::Duration::from_secs(30 * 24 * 60 * 60);
|
||
const ARTIST_ART_MANIFEST_MAX_BYTES: u64 = 64 * 1024;
|
||
// Identity is part of the key because two Deezer artists can share the same
|
||
// display name (and, occasionally, the same release title/reference artwork).
|
||
// The resolver's Apple candidate selection is artist-ID scoped as of v2.
|
||
const COVER_RESOLUTION_RECIPE: &str = "same-art-dhash10-v2-identity";
|
||
const COVER_RESOLUTION_TTL: std::time::Duration = std::time::Duration::from_secs(30 * 24 * 60 * 60);
|
||
const COVER_RESOLUTION_CACHE_MAX_FILES: usize = 4096;
|
||
const ARTIST_ART_MANIFEST_CACHE_MAX_FILES: usize = 512;
|
||
const PERSISTENT_CACHE_SWEEP_INTERVAL_SECS: u64 = 60 * 60;
|
||
|
||
fn artist_hero_gpu_prepare_slots() -> &'static tokio::sync::Semaphore {
|
||
static SLOTS: std::sync::OnceLock<tokio::sync::Semaphore> = std::sync::OnceLock::new();
|
||
SLOTS.get_or_init(|| tokio::sync::Semaphore::new(ARTIST_HERO_GPU_PREPARE_CONCURRENCY))
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize)]
|
||
struct CachedArtistArtManifest {
|
||
recipe: String,
|
||
artist_id: u64,
|
||
art: metadata::CatalogArtistArt,
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize)]
|
||
struct CachedCoverResolution {
|
||
recipe: String,
|
||
best_url: String,
|
||
}
|
||
|
||
fn maybe_sweep_cache_directory(
|
||
directory: &std::path::Path,
|
||
ttl: std::time::Duration,
|
||
max_files: usize,
|
||
last_sweep: &std::sync::atomic::AtomicU64,
|
||
file_count: &std::sync::atomic::AtomicUsize,
|
||
sweep_in_progress: &std::sync::atomic::AtomicBool,
|
||
) {
|
||
use std::sync::atomic::Ordering;
|
||
let now = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|duration| duration.as_secs())
|
||
.unwrap_or(0);
|
||
let previous = last_sweep.load(Ordering::Relaxed);
|
||
let sweep_due = now.saturating_sub(previous) >= PERSISTENT_CACHE_SWEEP_INTERVAL_SECS;
|
||
let at_capacity = file_count.load(Ordering::Relaxed) >= max_files;
|
||
if (!sweep_due && !at_capacity)
|
||
|| sweep_in_progress
|
||
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
||
.is_err()
|
||
{
|
||
return;
|
||
}
|
||
last_sweep.store(now, Ordering::Relaxed);
|
||
|
||
let Ok(entries) = std::fs::read_dir(directory) else {
|
||
sweep_in_progress.store(false, Ordering::Release);
|
||
return;
|
||
};
|
||
let mut retained = Vec::new();
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
let Ok(metadata) = entry.metadata() else {
|
||
continue;
|
||
};
|
||
if !metadata.is_file() {
|
||
continue;
|
||
}
|
||
let modified = metadata.modified().unwrap_or(std::time::UNIX_EPOCH);
|
||
let expired = modified.elapsed().is_ok_and(|age| age >= ttl);
|
||
if expired {
|
||
let _ = std::fs::remove_file(path);
|
||
} else {
|
||
retained.push((modified, path));
|
||
}
|
||
}
|
||
// This runs immediately before a write. Prune to a 90% low-water mark:
|
||
// leaving only one slot would force a 4K-entry directory scan + sort on
|
||
// every subsequent cover write once the cache first reached capacity.
|
||
let retained_limit = max_files
|
||
.saturating_mul(9)
|
||
.checked_div(10)
|
||
.unwrap_or(0)
|
||
.min(max_files.saturating_sub(1));
|
||
let mut removed = 0usize;
|
||
if retained.len() > retained_limit {
|
||
retained.sort_unstable_by_key(|(modified, _)| *modified);
|
||
removed = retained.len() - retained_limit;
|
||
for (_, path) in retained.iter().take(removed) {
|
||
let _ = std::fs::remove_file(path);
|
||
}
|
||
}
|
||
file_count.store(retained.len() - removed, Ordering::Relaxed);
|
||
sweep_in_progress.store(false, Ordering::Release);
|
||
}
|
||
|
||
fn cover_resolution_cache_path(
|
||
app: &tauri::AppHandle,
|
||
artist_id: Option<u64>,
|
||
artist: &str,
|
||
title: &str,
|
||
cover: &str,
|
||
) -> Result<std::path::PathBuf, String> {
|
||
use std::hash::{Hash as _, Hasher as _};
|
||
let dir = app
|
||
.path()
|
||
.app_cache_dir()
|
||
.map_err(|error| error.to_string())?
|
||
.join("cover-resolutions-v1");
|
||
std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
|
||
let mut key = std::collections::hash_map::DefaultHasher::new();
|
||
COVER_RESOLUTION_RECIPE.hash(&mut key);
|
||
artist_id.hash(&mut key);
|
||
artist.hash(&mut key);
|
||
title.hash(&mut key);
|
||
cover.hash(&mut key);
|
||
Ok(dir.join(format!("{:016x}.json", key.finish())))
|
||
}
|
||
|
||
async fn load_cover_resolution(
|
||
app: &tauri::AppHandle,
|
||
artist_id: Option<u64>,
|
||
artist: &str,
|
||
title: &str,
|
||
cover: &str,
|
||
) -> Option<String> {
|
||
let path = cover_resolution_cache_path(app, artist_id, artist, title, cover).ok()?;
|
||
let metadata = tokio::fs::metadata(&path).await.ok()?;
|
||
let fresh = metadata
|
||
.modified()
|
||
.ok()
|
||
.and_then(|modified| modified.elapsed().ok())
|
||
.is_some_and(|age| age < COVER_RESOLUTION_TTL);
|
||
if !fresh || metadata.len() > 4096 {
|
||
let _ = tokio::fs::remove_file(path).await;
|
||
return None;
|
||
}
|
||
let cached =
|
||
serde_json::from_slice::<CachedCoverResolution>(&tokio::fs::read(path).await.ok()?).ok()?;
|
||
(cached.recipe == COVER_RESOLUTION_RECIPE && cached.best_url.starts_with("https://"))
|
||
.then_some(cached.best_url)
|
||
}
|
||
|
||
fn store_cover_resolution(path: &std::path::Path, best_url: &str) -> Result<(), String> {
|
||
static LAST_SWEEP: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||
static FILE_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||
static SWEEP_IN_PROGRESS: std::sync::atomic::AtomicBool =
|
||
std::sync::atomic::AtomicBool::new(false);
|
||
if let Some(directory) = path.parent() {
|
||
maybe_sweep_cache_directory(
|
||
directory,
|
||
COVER_RESOLUTION_TTL,
|
||
COVER_RESOLUTION_CACHE_MAX_FILES,
|
||
&LAST_SWEEP,
|
||
&FILE_COUNT,
|
||
&SWEEP_IN_PROGRESS,
|
||
);
|
||
}
|
||
let replaces_existing = path.is_file();
|
||
let cached = CachedCoverResolution {
|
||
recipe: COVER_RESOLUTION_RECIPE.to_string(),
|
||
best_url: best_url.to_string(),
|
||
};
|
||
let bytes = serde_json::to_vec(&cached).map_err(|error| error.to_string())?;
|
||
let temporary = path.with_extension(format!("{}.tmp", std::process::id()));
|
||
std::fs::write(&temporary, bytes).map_err(|error| error.to_string())?;
|
||
if let Err(first_error) = std::fs::rename(&temporary, path) {
|
||
let _ = std::fs::remove_file(path);
|
||
if let Err(retry_error) = std::fs::rename(&temporary, path) {
|
||
let _ = std::fs::remove_file(&temporary);
|
||
return Err(format!(
|
||
"cover resolution cache rename failed ({first_error}); replacement failed ({retry_error})"
|
||
));
|
||
}
|
||
}
|
||
if !replaces_existing {
|
||
FILE_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn artist_art_manifest_path(
|
||
app: &tauri::AppHandle,
|
||
artist_id: u64,
|
||
) -> Result<std::path::PathBuf, String> {
|
||
let dir = app
|
||
.path()
|
||
.app_cache_dir()
|
||
.map_err(|error| error.to_string())?
|
||
.join("artist-art-manifests-v1");
|
||
std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
|
||
Ok(dir.join(format!("{artist_id}.json")))
|
||
}
|
||
|
||
/// A persisted manifest that passed structural validation, plus whether it can
|
||
/// be served without asking the network again.
|
||
struct StoredArtistArt {
|
||
art: metadata::CatalogArtistArt,
|
||
/// Fresh enough AND written by the current selection recipe.
|
||
current: bool,
|
||
}
|
||
|
||
/// Read a persisted manifest without destroying it.
|
||
///
|
||
/// This deliberately does NOT delete stale or older-recipe manifests. It used
|
||
/// to, and that is what blanked artists: the file was removed on the way in,
|
||
/// then discovery failed, and an artist that had a perfectly good hero five
|
||
/// minutes earlier had nothing at all. A known hero is only ever replaced by a
|
||
/// better one now -- see `catalog_artist_art`. Only genuinely unusable files
|
||
/// (oversized or unparseable) are removed.
|
||
async fn read_artist_art_manifest(
|
||
app: &tauri::AppHandle,
|
||
artist_id: u64,
|
||
) -> Option<StoredArtistArt> {
|
||
let path = artist_art_manifest_path(app, artist_id).ok()?;
|
||
let metadata = tokio::fs::metadata(&path).await.ok()?;
|
||
if metadata.len() > ARTIST_ART_MANIFEST_MAX_BYTES {
|
||
let _ = tokio::fs::remove_file(path).await;
|
||
return None;
|
||
}
|
||
let fresh = metadata
|
||
.modified()
|
||
.ok()
|
||
.and_then(|modified| modified.elapsed().ok())
|
||
.is_some_and(|age| age < ARTIST_ART_MANIFEST_TTL);
|
||
let bytes = tokio::fs::read(&path).await.ok()?;
|
||
let Ok(manifest) = serde_json::from_slice::<CachedArtistArtManifest>(&bytes) else {
|
||
let _ = tokio::fs::remove_file(path).await;
|
||
return None;
|
||
};
|
||
let art = manifest.art;
|
||
let valid_urls = art
|
||
.banners
|
||
.iter()
|
||
.chain(art.logos.iter())
|
||
.all(|url| url.starts_with("https://"));
|
||
// Identity and shape must hold whatever the recipe was; a manifest for the
|
||
// wrong artist is never usable, however recent.
|
||
if manifest.artist_id != artist_id
|
||
|| art.artist.id != artist_id
|
||
|| art.banner_custom
|
||
|| art.banners.len() > 8
|
||
|| art.logos.len() > 8
|
||
|| !valid_urls
|
||
{
|
||
return None;
|
||
}
|
||
let current = fresh && manifest.recipe == ARTIST_ART_MANIFEST_RECIPE;
|
||
Some(StoredArtistArt { art, current })
|
||
}
|
||
|
||
/// The strict read: only a manifest that is fresh and matches the current
|
||
/// recipe. Callers that must not show an out-of-recipe hero use this.
|
||
async fn load_artist_art_manifest(
|
||
app: &tauri::AppHandle,
|
||
artist_id: u64,
|
||
) -> Option<metadata::CatalogArtistArt> {
|
||
read_artist_art_manifest(app, artist_id)
|
||
.await
|
||
.and_then(|stored| stored.current.then_some(stored.art))
|
||
}
|
||
|
||
/// Persist a resolved manifest off the request path. Re-stamps whatever is
|
||
/// written with the current recipe, which is also how an older-recipe manifest
|
||
/// that survived as a floor gets migrated forward.
|
||
fn store_art_manifest_in_background(
|
||
app: &tauri::AppHandle,
|
||
artist_id: u64,
|
||
art: &metadata::CatalogArtistArt,
|
||
) {
|
||
let store_app = app.clone();
|
||
let store_art = art.clone();
|
||
tauri::async_runtime::spawn(async move {
|
||
let result = tokio::task::spawn_blocking(move || {
|
||
store_artist_art_manifest(&store_app, artist_id, &store_art)
|
||
})
|
||
.await;
|
||
if let Ok(Err(error)) = result {
|
||
log::warn!("could not cache artist artwork manifest: {error}");
|
||
}
|
||
});
|
||
}
|
||
|
||
fn store_artist_art_manifest(
|
||
app: &tauri::AppHandle,
|
||
artist_id: u64,
|
||
art: &metadata::CatalogArtistArt,
|
||
) -> Result<(), String> {
|
||
if art.banners.is_empty() && art.logos.is_empty() {
|
||
return Ok(());
|
||
}
|
||
let path = artist_art_manifest_path(app, artist_id)?;
|
||
static LAST_SWEEP: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||
static FILE_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||
static SWEEP_IN_PROGRESS: std::sync::atomic::AtomicBool =
|
||
std::sync::atomic::AtomicBool::new(false);
|
||
if let Some(directory) = path.parent() {
|
||
maybe_sweep_cache_directory(
|
||
directory,
|
||
ARTIST_ART_MANIFEST_TTL,
|
||
ARTIST_ART_MANIFEST_CACHE_MAX_FILES,
|
||
&LAST_SWEEP,
|
||
&FILE_COUNT,
|
||
&SWEEP_IN_PROGRESS,
|
||
);
|
||
}
|
||
let replaces_existing = path.is_file();
|
||
let manifest = CachedArtistArtManifest {
|
||
recipe: ARTIST_ART_MANIFEST_RECIPE.to_string(),
|
||
artist_id,
|
||
art: art.clone(),
|
||
};
|
||
let bytes = serde_json::to_vec(&manifest).map_err(|error| error.to_string())?;
|
||
if bytes.len() as u64 > ARTIST_ART_MANIFEST_MAX_BYTES {
|
||
return Err("artist artwork manifest is unexpectedly large".into());
|
||
}
|
||
let temporary = path.with_extension(format!("{}.tmp", std::process::id()));
|
||
std::fs::write(&temporary, bytes).map_err(|error| error.to_string())?;
|
||
if let Err(first_error) = std::fs::rename(&temporary, &path) {
|
||
let _ = std::fs::remove_file(&path);
|
||
if let Err(retry_error) = std::fs::rename(&temporary, &path) {
|
||
let _ = std::fs::remove_file(&temporary);
|
||
return Err(format!(
|
||
"artist art manifest rename failed ({first_error}); replacement failed ({retry_error})"
|
||
));
|
||
}
|
||
}
|
||
if !replaces_existing {
|
||
FILE_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct ArtistHeroOverride {
|
||
pub data_url: String,
|
||
pub width: u32,
|
||
pub height: u32,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct CatalogArtistHeroSeed {
|
||
pub banner: String,
|
||
pub banner_custom: bool,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct CatalogHeroArtResult {
|
||
pub data_url: String,
|
||
pub cacheable: bool,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct CatalogHeroCropPlan {
|
||
pub recipe: String,
|
||
pub source_checksum: String,
|
||
#[serde(flatten)]
|
||
pub geometry: HeroCropPlan,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
pub struct CatalogHeroPreviewResult {
|
||
pub data_url: String,
|
||
pub plan: CatalogHeroCropPlan,
|
||
}
|
||
|
||
/// Header preceding the raw RGBA payload returned to the live GPU hero path.
|
||
/// The WebView receives this as an ArrayBuffer through Tauri's binary IPC,
|
||
/// avoiding both base64 expansion and a lossy JPEG round-trip:
|
||
///
|
||
/// magic[4] | width(u32 LE) | height(u32 LE) | grain_seed(u32 LE) | RGBA8
|
||
const CATALOG_HERO_GPU_MAGIC: &[u8; 4] = b"SHG1";
|
||
const CATALOG_HERO_GPU_HEADER_BYTES: usize = 16;
|
||
|
||
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 stored = read_artist_art_manifest(&app, artist_id).await;
|
||
let mut art = match stored {
|
||
// Fresh, current recipe: serve it, no network.
|
||
Some(stored) if stored.current => stored.art,
|
||
// Either nothing on disk, or something stale/older-recipe. Try to do
|
||
// better, but keep what we have as a floor: the discovery tier fails
|
||
// per-artist-name (Bing collapses rare names onto popular entities and
|
||
// intermittently serves other queries' results), so a failed lookup is
|
||
// a normal event, not a reason to show an artist with no hero.
|
||
other => {
|
||
let resolved = state.metadata.catalog_artist_art(artist_id).await;
|
||
// Only banners we already had count as a floor. Note that
|
||
// `catalog_artist_art` returns Some as soon as the Deezer *profile*
|
||
// resolves, so "found nothing" arrives as Some with an empty banner
|
||
// list -- an emptiness check, not a None check, is what detects a
|
||
// failed hero discovery.
|
||
let previous = other
|
||
.map(|stored| stored.art)
|
||
.filter(|art| !art.banners.is_empty());
|
||
match (resolved, previous) {
|
||
(Some(mut resolved), Some(previous)) if resolved.banners.is_empty() => {
|
||
log::info!(
|
||
"artist {artist_id}: hero discovery found nothing, keeping {} \
|
||
previously resolved banner(s)",
|
||
previous.banners.len()
|
||
);
|
||
resolved.banner = previous.banner;
|
||
resolved.banners = previous.banners;
|
||
if resolved.logos.is_empty() {
|
||
resolved.logos = previous.logos;
|
||
}
|
||
store_art_manifest_in_background(&app, artist_id, &resolved);
|
||
resolved
|
||
}
|
||
(Some(resolved), _) => {
|
||
store_art_manifest_in_background(&app, artist_id, &resolved);
|
||
resolved
|
||
}
|
||
(None, Some(previous)) => previous,
|
||
(None, None) => return Err("artist artwork lookup failed".to_string()),
|
||
}
|
||
}
|
||
};
|
||
if let Some(custom) = load_artist_hero_override(&app, artist_id)? {
|
||
art.banner = Some(custom.data_url.clone());
|
||
art.banners = vec![custom.data_url];
|
||
art.banner_custom = true;
|
||
}
|
||
Ok(art)
|
||
}
|
||
|
||
/// Definitive provider-logo candidates without waiting for the independent
|
||
/// Bing hero fallback. The frontend uses this to decide whether cover OCR is
|
||
/// allowed as soon as the exact release list is ready.
|
||
#[tauri::command]
|
||
pub async fn catalog_artist_logos(
|
||
state: State<'_, LibraryState>,
|
||
artist_id: u64,
|
||
) -> Result<Vec<String>, String> {
|
||
state
|
||
.metadata
|
||
.catalog_artist_logos(artist_id)
|
||
.await
|
||
.ok_or_else(|| "artist logo lookup failed".to_string())
|
||
}
|
||
|
||
/// Deferred MusicBrainz pass — MB-only releases + type hints. Kept out of
|
||
/// `catalog_artist` so its rate-limited (1 req/s) calls never delay the grid.
|
||
/// Socials ride `catalog_artist_socials` below, off this critical path.
|
||
#[tauri::command]
|
||
pub async fn catalog_artist_identity(
|
||
state: State<'_, LibraryState>,
|
||
artist_id: u64,
|
||
artist: String,
|
||
) -> Result<metadata::CatalogArtistIdentity, String> {
|
||
Ok(state
|
||
.metadata
|
||
.catalog_artist_identity(artist_id, &artist)
|
||
.await)
|
||
}
|
||
|
||
/// Identity-anchored artist biography for the Now Playing panel. Replaces a
|
||
/// frontend "first Wikipedia hit for `<name> musician band`" lookup that
|
||
/// regularly described a different artist entirely, and which also bypassed
|
||
/// this client's Wikimedia UA/pacing/caching.
|
||
#[tauri::command]
|
||
pub async fn catalog_artist_bio(
|
||
state: State<'_, LibraryState>,
|
||
artist_id: Option<u64>,
|
||
artist: String,
|
||
) -> Result<Option<String>, String> {
|
||
state.touch_catalog_activity();
|
||
Ok(state.metadata.artist_bio(artist_id, &artist).await)
|
||
}
|
||
|
||
/// 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);
|
||
crate::diagnostics::clear_stream(&app);
|
||
let app2 = app.clone();
|
||
let cache2 = cache.clone();
|
||
crate::loudness::on_track_started(
|
||
&app,
|
||
&cache,
|
||
size,
|
||
None,
|
||
Box::new(move || {
|
||
app2.state::<PlayerState>()
|
||
.player
|
||
.play_local(&cache2, duration);
|
||
}),
|
||
);
|
||
return Ok(());
|
||
}
|
||
let _ = std::fs::remove_file(&cache);
|
||
}
|
||
let partial = playback::partial_path(&cache);
|
||
|
||
// A preload may already be fetching this exact file: attach to it and
|
||
// keep the bytes it has instead of restarting the transfer.
|
||
let preload_id = players
|
||
.preloads
|
||
.lock()
|
||
.unwrap()
|
||
.iter()
|
||
.find(|(_, c)| **c == cache)
|
||
.map(|(id, _)| *id);
|
||
if let Some(id) = preload_id {
|
||
players.preloads.lock().unwrap().remove(&id);
|
||
let progress = player::StreamProgress::new();
|
||
if let Ok(md) = std::fs::metadata(&partial) {
|
||
progress.update(md.len(), false, None);
|
||
}
|
||
players.streams.lock().unwrap().insert(id, progress.clone());
|
||
players
|
||
.stream_caches
|
||
.lock()
|
||
.unwrap()
|
||
.insert(id, cache.clone());
|
||
crate::diagnostics::note_stream(&app, size, progress.clone());
|
||
let app2 = app.clone();
|
||
let partial2 = partial.clone();
|
||
let progress2 = progress.clone();
|
||
crate::loudness::on_track_started(
|
||
&app,
|
||
&cache,
|
||
size,
|
||
Some((partial, progress)),
|
||
Box::new(move || {
|
||
app2.state::<PlayerState>()
|
||
.player
|
||
.play_stream(&partial2, progress2, size, duration);
|
||
}),
|
||
);
|
||
return Ok(());
|
||
}
|
||
|
||
let _ = std::fs::remove_file(&partial);
|
||
|
||
let progress = player::StreamProgress::new();
|
||
let id = {
|
||
let guard = state.client.lock().await;
|
||
let client = guard.as_ref().ok_or("not connected")?;
|
||
client.download_to(&username, &path, size, cache.clone())
|
||
};
|
||
players.streams.lock().unwrap().insert(id, progress.clone());
|
||
players
|
||
.stream_caches
|
||
.lock()
|
||
.unwrap()
|
||
.insert(id, cache.clone());
|
||
|
||
crate::diagnostics::note_stream(&app, size, progress.clone());
|
||
let app2 = app.clone();
|
||
let partial2 = partial.clone();
|
||
let progress2 = progress.clone();
|
||
crate::loudness::on_track_started(
|
||
&app,
|
||
&cache,
|
||
size,
|
||
Some((partial, progress)),
|
||
Box::new(move || {
|
||
app2.state::<PlayerState>()
|
||
.player
|
||
.play_stream(&partial2, progress2, size, duration);
|
||
}),
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
/// Quietly download the next queue track into the stream cache so playback
|
||
/// can move to it without a buffering gap. No player, UI, or library
|
||
/// side-effects; play_stream attaches to it when the track comes up.
|
||
#[tauri::command]
|
||
pub async fn preload_stream(
|
||
app: tauri::AppHandle,
|
||
state: State<'_, AppState>,
|
||
players: State<'_, PlayerState>,
|
||
username: String,
|
||
path: String,
|
||
size: u64,
|
||
) -> Result<(), String> {
|
||
let cache = playback::cache_path(&app, &username, &path).ok_or("no cache dir")?;
|
||
if let Ok(md) = std::fs::metadata(&cache) {
|
||
if size > 0 && md.len() == size {
|
||
// Gapless normalization: boundary gain ready before the switch.
|
||
crate::loudness::on_preload_ready(&app, &cache);
|
||
return Ok(()); // already fully cached
|
||
}
|
||
let _ = std::fs::remove_file(&cache);
|
||
}
|
||
if players
|
||
.preloads
|
||
.lock()
|
||
.unwrap()
|
||
.values()
|
||
.any(|c| *c == cache)
|
||
{
|
||
return Ok(()); // already preloading
|
||
}
|
||
let _ = std::fs::remove_file(&playback::partial_path(&cache));
|
||
|
||
let id = {
|
||
let guard = state.client.lock().await;
|
||
let client = guard.as_ref().ok_or("not connected")?;
|
||
client.download_to(&username, &path, size, cache.clone())
|
||
};
|
||
players.preloads.lock().unwrap().insert(id, cache);
|
||
Ok(())
|
||
}
|
||
|
||
/// Append a fully cached stream track behind the active source so the
|
||
/// transition happens inside the same audio sink (true gapless). Returns
|
||
/// false — and does nothing — while the cache file is still incomplete.
|
||
#[tauri::command]
|
||
pub fn preload_cached_stream(
|
||
app: tauri::AppHandle,
|
||
players: State<'_, PlayerState>,
|
||
username: String,
|
||
path: String,
|
||
size: u64,
|
||
duration: Option<f64>,
|
||
) -> Result<bool, String> {
|
||
let cache = playback::cache_path(&app, &username, &path).ok_or("no cache dir")?;
|
||
match std::fs::metadata(&cache) {
|
||
Ok(md) if size == 0 || md.len() == size => {
|
||
playback::touch(&cache);
|
||
players.player.preload_local(&cache, duration);
|
||
// Gapless normalization: boundary gain ready before the switch.
|
||
crate::loudness::on_preload_ready(&app, &cache);
|
||
Ok(true)
|
||
}
|
||
_ => Ok(false),
|
||
}
|
||
}
|
||
|
||
/// Waveform peaks for a stream-cached track. While the download is still in
|
||
/// flight, the partial file is decoded and its peaks scaled into the leading
|
||
/// `received/size` share of the buckets (rest zero), so the waveform appears
|
||
/// immediately and grows with the transfer. Complete files are memoized.
|
||
#[tauri::command]
|
||
pub async fn stream_waveform(
|
||
app: tauri::AppHandle,
|
||
players: State<'_, PlayerState>,
|
||
username: String,
|
||
path: String,
|
||
size: u64,
|
||
buckets: usize,
|
||
) -> Result<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(
|
||
app: tauri::AppHandle,
|
||
path: String,
|
||
duration: Option<f64>,
|
||
) -> Result<(), String> {
|
||
if std::fs::metadata(&path).is_err() {
|
||
return Err("file not found".into());
|
||
}
|
||
crate::diagnostics::clear_stream(&app);
|
||
let app2 = app.clone();
|
||
let path2 = path.clone();
|
||
crate::loudness::on_track_started(
|
||
&app,
|
||
std::path::Path::new(&path),
|
||
0,
|
||
None,
|
||
Box::new(move || {
|
||
app2.state::<PlayerState>().player.play_local(&path2, 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(
|
||
app: tauri::AppHandle,
|
||
players: State<'_, PlayerState>,
|
||
path: String,
|
||
duration: Option<f64>,
|
||
) -> Result<(), String> {
|
||
if std::fs::metadata(&path).is_err() {
|
||
return Err("file not found".into());
|
||
}
|
||
players.player.preload_local(&path, duration);
|
||
// Gapless normalization: have the boundary gain ready before the switch.
|
||
crate::loudness::on_preload_ready(&app, std::path::Path::new(&path));
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn library_albums(lib: State<'_, LibraryState>) -> Result<Vec<library::Album>, String> {
|
||
let l = lib.library.clone();
|
||
tauri::async_runtime::spawn_blocking(move || l.list_albums().map_err(|e| e.to_string()))
|
||
.await
|
||
.map_err(|e| e.to_string())?
|
||
}
|
||
|
||
#[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));
|
||
}
|
||
}
|