Files
qbz/src-tauri/src/commands_v2/library.rs
T
vicrodh 133e90f752 fix(offline): hysteresis + cheap probe + streaming liveness override (#467)
Long sessions died when a single transient blip flipped the app to offline and
the queue then skipped every cached-but-not-downloaded track. Harden the
detector:

- Consecutive-failure hysteresis: declare offline only after 2 failed polls,
  not a single blip.
- Replace the HEAD https://1.1.1.1 probe with a concurrent race of cheap
  generate_204 endpoints, so a saturated link (long Hi-Res tracks) no longer
  times the probe out.
- Positive liveness override: if a segment downloaded within the last 45s we
  are online by definition, regardless of the probe.

cc
2026-05-22 21:46:33 -06:00

3373 lines
113 KiB
Rust

use tauri::State;
use crate::api_cache::ApiCacheState;
use crate::artist_blacklist::BlacklistState;
use crate::cast::{CastState, DlnaMetadata, DlnaState, MediaMetadata};
use crate::config::download_settings::DownloadSettingsState;
use crate::core_bridge::CoreBridgeState;
use crate::integrations_v2::MusicBrainzV2State;
use crate::library::{
get_artwork_cache_dir, thumbnails, AudioFormat, LibraryState, LocalAlbum, LocalTrack,
MetadataExtractor, PlaylistLocalTrack, PlaylistSettings, PlaylistStats, ScanProgress,
};
use qbz_library::FolderTreeEntry;
use crate::lyrics::LyricsState;
use crate::offline::OfflineState;
use crate::offline_cache::OfflineCacheState;
use crate::plex::{PlexMusicSection, PlexPlayResult, PlexTrack};
use crate::reco_store::{HomeResolved, HomeSeeds, RecoEventInput, RecoState};
use crate::runtime::{CommandRequirement, RuntimeManagerState};
use crate::AppState;
use md5::{Digest, Md5};
// ==================== Casting / Local Library Commands (V2 Native) ====================
#[tauri::command]
pub async fn v2_cast_start_discovery(state: State<'_, CastState>) -> Result<(), String> {
let mut discovery = state.discovery.lock().await;
discovery.start_discovery().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_cast_stop_discovery(state: State<'_, CastState>) -> Result<(), String> {
let mut discovery = state.discovery.lock().await;
discovery.stop_discovery().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_cast_get_devices(
state: State<'_, CastState>,
) -> Result<Vec<crate::cast::DiscoveredDevice>, String> {
let discovery = state.discovery.lock().await;
Ok(discovery.get_discovered_devices())
}
#[tauri::command]
pub async fn v2_cast_connect(device_id: String, state: State<'_, CastState>) -> Result<(), String> {
let device = {
let discovery = state.discovery.lock().await;
discovery
.get_device(&device_id)
.ok_or_else(|| format!("Device not found: {}", device_id))?
};
state
.chromecast
.connect(device.ip.clone(), device.port)
.map_err(|e| e.to_string())?;
let mut connected = state.connected_device_ip.lock().await;
*connected = Some(device.ip);
Ok(())
}
#[tauri::command]
pub async fn v2_cast_disconnect(state: State<'_, CastState>) -> Result<(), String> {
state.chromecast.disconnect().map_err(|e| e.to_string())?;
let mut connected = state.connected_device_ip.lock().await;
*connected = None;
Ok(())
}
#[tauri::command]
pub async fn v2_cast_play_track(
track_id: u64,
metadata: MediaMetadata,
cast_state: State<'_, CastState>,
app_state: State<'_, AppState>,
) -> Result<(), String> {
let stream_url = {
let client = app_state.client.read().await;
client
.get_stream_url_with_fallback(track_id, crate::api::models::Quality::HiRes)
.await
.map_err(|e| format!("Failed to get stream URL: {}", e))?
};
let content_type = stream_url.mime_type.clone();
let cache = app_state.audio_cache.clone();
// TODO: Add CMAF fallback when CoreBridge is accessible here
// (currently uses legacy QobuzClient via AppState; needs CoreBridgeState param)
let audio_data = if let Some(cached) = cache.get(track_id) {
cached.data
} else {
let data = super::download_audio(&stream_url.url).await?;
cache.insert(track_id, data.clone());
data
};
let target_ip = {
let connected = cast_state.connected_device_ip.lock().await;
connected.clone()
};
cast_state
.get_or_create_media_server()
.await
.map_err(|e| e.to_string())?;
let url = {
let mut server_guard = cast_state.media_server.lock().await;
let server = server_guard
.as_mut()
.ok_or("Media server not initialized")?;
server.register_audio(track_id, audio_data, &content_type);
match target_ip.as_deref() {
Some(ip) => server.get_audio_url_for_target(track_id, ip),
None => server.get_audio_url(track_id),
}
.ok_or_else(|| "Failed to build media URL".to_string())?
};
cast_state
.chromecast
.load_media(url, content_type, metadata)
.map_err(|e| e.to_string())
}
/// Play a LOCAL library track on a Chromecast device. Companion to
/// v2_dlna_play_local_track — same routing problem, same solution pattern.
/// Reads the file from disk, infers content_type, registers bytes with the
/// media server, loads the URL on Chromecast.
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_cast_play_local_track(
trackId: i64,
metadata: MediaMetadata,
cast_state: State<'_, CastState>,
library_state: State<'_, LibraryState>,
) -> Result<(), String> {
log::info!("Chromecast: cast_play_local_track called for track_id={}", trackId);
let track = {
let guard = library_state.db.lock().await;
let db = guard.as_ref().ok_or("No active session")?;
db.get_track(trackId)
.map_err(|e| format!("Library lookup failed: {}", e))?
.ok_or_else(|| format!("Track {} not found in library", trackId))?
};
let audio_data = std::fs::read(&track.file_path)
.map_err(|e| format!("Failed to read {}: {}", track.file_path, e))?;
let content_type = local_track_content_type(&track);
let target_ip = {
let connected = cast_state.connected_device_ip.lock().await;
connected.clone()
};
cast_state
.get_or_create_media_server()
.await
.map_err(|e| e.to_string())?;
let media_key = trackId as u64;
let url = {
let mut server_guard = cast_state.media_server.lock().await;
let server = server_guard
.as_mut()
.ok_or("Media server not initialized")?;
server.register_audio(media_key, audio_data, &content_type);
match target_ip.as_deref() {
Some(ip) => server.get_audio_url_for_target(media_key, ip),
None => server.get_audio_url(media_key),
}
.ok_or_else(|| "Failed to build media URL".to_string())?
};
log::info!(
"Chromecast: Playing local track {} ({}) via MediaServer URL: {}",
trackId,
content_type,
url
);
cast_state
.chromecast
.load_media(url, content_type, metadata)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_cast_play(state: State<'_, CastState>) -> Result<(), String> {
state.chromecast.play().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_cast_pause(state: State<'_, CastState>) -> Result<(), String> {
state.chromecast.pause().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_cast_stop(state: State<'_, CastState>) -> Result<(), String> {
state.chromecast.stop().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_cast_seek(position_secs: f64, state: State<'_, CastState>) -> Result<(), String> {
state
.chromecast
.seek(position_secs)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_cast_get_position(
state: State<'_, CastState>,
) -> Result<crate::cast::CastPositionInfo, String> {
state
.chromecast
.get_media_position()
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_cast_set_volume(volume: f32, state: State<'_, CastState>) -> Result<(), String> {
state
.chromecast
.set_volume(volume)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_dlna_start_discovery(state: State<'_, DlnaState>) -> Result<(), String> {
let mut discovery = state.discovery.lock().await;
discovery.start_discovery().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_dlna_stop_discovery(state: State<'_, DlnaState>) -> Result<(), String> {
let mut discovery = state.discovery.lock().await;
discovery.stop_discovery().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_dlna_get_devices(
state: State<'_, DlnaState>,
) -> Result<Vec<crate::cast::DiscoveredDlnaDevice>, String> {
let discovery = state.discovery.lock().await;
Ok(discovery.get_discovered_devices())
}
#[tauri::command]
pub async fn v2_dlna_connect(device_id: String, state: State<'_, DlnaState>) -> Result<(), String> {
let device = {
let discovery = state.discovery.lock().await;
discovery
.get_device(&device_id)
.ok_or_else(|| format!("Device not found: {}", device_id))?
};
let connection = crate::cast::DlnaConnection::connect(device)
.await
.map_err(|e| e.to_string())?;
let mut state_connection = state.connection.lock().await;
*state_connection = Some(connection);
Ok(())
}
#[tauri::command]
pub async fn v2_dlna_disconnect(state: State<'_, DlnaState>) -> Result<(), String> {
let mut connection = state.connection.lock().await;
if let Some(conn) = connection.as_mut() {
conn.disconnect().map_err(|e| e.to_string())?;
}
*connection = None;
Ok(())
}
#[tauri::command]
pub async fn v2_dlna_play_track(
track_id: u64,
metadata: DlnaMetadata,
dlna_state: State<'_, DlnaState>,
app_state: State<'_, AppState>,
) -> Result<(), String> {
let stream_url = {
let client = app_state.client.read().await;
client
.get_stream_url_with_fallback(track_id, crate::api::models::Quality::HiRes)
.await
.map_err(|e| format!("Failed to get stream URL: {}", e))?
};
let content_type = stream_url.mime_type.clone();
let cache = app_state.audio_cache.clone();
// TODO: Add CMAF fallback when CoreBridge is accessible here
// (currently uses legacy QobuzClient via AppState; needs CoreBridgeState param)
let audio_data = if let Some(cached) = cache.get(track_id) {
cached.data
} else {
let data = super::download_audio(&stream_url.url).await?;
cache.insert(track_id, data.clone());
data
};
let target_ip = {
let connection = dlna_state.connection.lock().await;
connection.as_ref().map(|conn| conn.device_ip().to_string())
};
dlna_state
.ensure_media_server()
.await
.map_err(|e| e.to_string())?;
let url = {
let mut server_guard = dlna_state.media_server.lock().await;
let server = server_guard
.as_mut()
.ok_or("Media server not initialized")?;
server.register_audio(track_id, audio_data, &content_type);
match target_ip.as_deref() {
Some(ip) => server.get_audio_url_for_target(track_id, ip),
None => server.get_audio_url(track_id),
}
.ok_or_else(|| "Failed to build media URL".to_string())?
};
{
let mut connection = dlna_state.connection.lock().await;
let conn = connection.as_mut().ok_or("Not connected")?;
conn.load_media(&url, &metadata, &content_type)
.await
.map_err(|e| e.to_string())?;
}
{
let mut connection = dlna_state.connection.lock().await;
let conn = connection.as_mut().ok_or("Not connected")?;
conn.play().await.map_err(|e| e.to_string())?;
}
Ok(())
}
/// Play a LOCAL library track on a DLNA renderer. Reads the file from disk,
/// infers content_type from the track's format, registers bytes with the
/// media server, and sends AVTransport URI + Play to the renderer.
///
/// Mirrors `v2_dlna_play_track` but for local-source tracks — that command
/// resolves a Qobuz stream URL and would fail for a library row id. Without
/// this command, casting any local-library track silently falls back to the
/// app's local audio backend (issue #332).
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_dlna_play_local_track(
trackId: i64,
metadata: DlnaMetadata,
dlna_state: State<'_, DlnaState>,
library_state: State<'_, LibraryState>,
) -> Result<(), String> {
log::info!("DLNA: dlna_play_local_track called for track_id={}", trackId);
let track = {
let guard = library_state.db.lock().await;
let db = guard.as_ref().ok_or("No active session")?;
db.get_track(trackId)
.map_err(|e| format!("Library lookup failed: {}", e))?
.ok_or_else(|| format!("Track {} not found in library", trackId))?
};
let audio_data = std::fs::read(&track.file_path)
.map_err(|e| format!("Failed to read {}: {}", track.file_path, e))?;
let content_type = local_track_content_type(&track);
let target_ip = {
let connection = dlna_state.connection.lock().await;
connection.as_ref().map(|conn| conn.device_ip().to_string())
};
dlna_state
.ensure_media_server()
.await
.map_err(|e| e.to_string())?;
// Library row ids are small positive autoincrement integers; using them
// directly as MediaServer keys won't collide with Qobuz track ids within
// a single cast session (the server is ephemeral per cast connection).
let media_key = trackId as u64;
let url = {
let mut server_guard = dlna_state.media_server.lock().await;
let server = server_guard
.as_mut()
.ok_or("Media server not initialized")?;
server.register_audio(media_key, audio_data, &content_type);
match target_ip.as_deref() {
Some(ip) => server.get_audio_url_for_target(media_key, ip),
None => server.get_audio_url(media_key),
}
.ok_or_else(|| "Failed to build media URL".to_string())?
};
log::info!(
"DLNA: Playing local track {} ({}) via MediaServer URL: {}",
trackId,
content_type,
url
);
{
let mut connection = dlna_state.connection.lock().await;
let conn = connection.as_mut().ok_or("Not connected")?;
conn.load_media(&url, &metadata, &content_type)
.await
.map_err(|e| e.to_string())?;
}
{
let mut connection = dlna_state.connection.lock().await;
let conn = connection.as_mut().ok_or("Not connected")?;
conn.play().await.map_err(|e| e.to_string())?;
}
Ok(())
}
fn local_track_content_type(track: &LocalTrack) -> String {
match track.format {
AudioFormat::Flac => "audio/flac".to_string(),
AudioFormat::Alac => "audio/mp4".to_string(),
AudioFormat::Wav => "audio/wav".to_string(),
AudioFormat::Aiff => "audio/aiff".to_string(),
AudioFormat::Ape => "audio/x-ape".to_string(),
AudioFormat::Mp3 => "audio/mpeg".to_string(),
AudioFormat::Unknown => {
let ext = std::path::Path::new(&track.file_path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
match ext.as_str() {
"flac" => "audio/flac",
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"m4a" | "mp4" | "aac" => "audio/mp4",
"aiff" | "aif" => "audio/aiff",
"ogg" | "oga" => "audio/ogg",
"opus" => "audio/opus",
_ => "audio/octet-stream",
}
.to_string()
}
}
}
/// Play a PLEX track on a DLNA renderer. Plex serves audio via its own HTTP
/// server, but streaming directly from Plex to the DLNA renderer would
/// require the renderer to present the user's Plex token — we proxy the
/// bytes through our local media server instead so auth stays ours.
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_dlna_play_plex_track(
baseUrl: String,
token: String,
ratingKey: String,
metadata: DlnaMetadata,
dlna_state: State<'_, DlnaState>,
) -> Result<(), String> {
log::info!("DLNA: dlna_play_plex_track called for rating_key={}", ratingKey);
let resolved =
crate::plex::plex_resolve_track_media(baseUrl, token, ratingKey.clone()).await?;
// Plex sometimes omits content-type on transcoded streams; audio/mpeg is
// the most common transcode target and a safe DLNA fallback.
let content_type = resolved
.content_type
.clone()
.unwrap_or_else(|| "audio/mpeg".to_string());
let target_ip = {
let connection = dlna_state.connection.lock().await;
connection.as_ref().map(|conn| conn.device_ip().to_string())
};
dlna_state
.ensure_media_server()
.await
.map_err(|e| e.to_string())?;
let media_key = plex_key_to_u64(&ratingKey);
let url = {
let mut server_guard = dlna_state.media_server.lock().await;
let server = server_guard
.as_mut()
.ok_or("Media server not initialized")?;
server.register_audio(media_key, resolved.bytes.clone(), &content_type);
match target_ip.as_deref() {
Some(ip) => server.get_audio_url_for_target(media_key, ip),
None => server.get_audio_url(media_key),
}
.ok_or_else(|| "Failed to build media URL".to_string())?
};
log::info!(
"DLNA: Playing plex track {} ({}) via MediaServer URL: {}",
ratingKey,
content_type,
url
);
{
let mut connection = dlna_state.connection.lock().await;
let conn = connection.as_mut().ok_or("Not connected")?;
conn.load_media(&url, &metadata, &content_type)
.await
.map_err(|e| e.to_string())?;
}
{
let mut connection = dlna_state.connection.lock().await;
let conn = connection.as_mut().ok_or("Not connected")?;
conn.play().await.map_err(|e| e.to_string())?;
}
Ok(())
}
/// Play a PLEX track on a Chromecast device. Companion to v2_dlna_play_plex_track.
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_cast_play_plex_track(
baseUrl: String,
token: String,
ratingKey: String,
metadata: MediaMetadata,
cast_state: State<'_, CastState>,
) -> Result<(), String> {
log::info!("Chromecast: cast_play_plex_track called for rating_key={}", ratingKey);
let resolved =
crate::plex::plex_resolve_track_media(baseUrl, token, ratingKey.clone()).await?;
// Plex sometimes omits content-type on transcoded streams; audio/mpeg is
// the most common transcode target and a safe DLNA fallback.
let content_type = resolved
.content_type
.clone()
.unwrap_or_else(|| "audio/mpeg".to_string());
let target_ip = {
let connected = cast_state.connected_device_ip.lock().await;
connected.clone()
};
cast_state
.get_or_create_media_server()
.await
.map_err(|e| e.to_string())?;
let media_key = plex_key_to_u64(&ratingKey);
let url = {
let mut server_guard = cast_state.media_server.lock().await;
let server = server_guard
.as_mut()
.ok_or("Media server not initialized")?;
server.register_audio(media_key, resolved.bytes.clone(), &content_type);
match target_ip.as_deref() {
Some(ip) => server.get_audio_url_for_target(media_key, ip),
None => server.get_audio_url(media_key),
}
.ok_or_else(|| "Failed to build media URL".to_string())?
};
log::info!(
"Chromecast: Playing plex track {} ({}) via MediaServer URL: {}",
ratingKey,
content_type,
url
);
cast_state
.chromecast
.load_media(url, content_type, metadata)
.map_err(|e| e.to_string())
}
/// Namespace a Plex rating key into u64 for the MediaServer. Most Plex
/// ratingKey strings parse as numeric; fall back to a stable hash so
/// non-numeric keys still route correctly and don't collide with library
/// ids in the low u64 range. High bit set to separate from Qobuz/library.
fn plex_key_to_u64(rating_key: &str) -> u64 {
const PLEX_NAMESPACE: u64 = 0x4000_0000_0000_0000;
if let Ok(n) = rating_key.parse::<u64>() {
return PLEX_NAMESPACE | (n & !PLEX_NAMESPACE);
}
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
rating_key.hash(&mut hasher);
PLEX_NAMESPACE | (hasher.finish() & !PLEX_NAMESPACE)
}
#[tauri::command]
pub async fn v2_dlna_play(state: State<'_, DlnaState>) -> Result<(), String> {
let mut connection = state.connection.lock().await;
let conn = connection.as_mut().ok_or("Not connected")?;
conn.play().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_dlna_pause(state: State<'_, DlnaState>) -> Result<(), String> {
let mut connection = state.connection.lock().await;
let conn = connection.as_mut().ok_or("Not connected")?;
conn.pause().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_dlna_stop(state: State<'_, DlnaState>) -> Result<(), String> {
let mut connection = state.connection.lock().await;
let conn = connection.as_mut().ok_or("Not connected")?;
conn.stop().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_dlna_seek(position_secs: u64, state: State<'_, DlnaState>) -> Result<(), String> {
let mut connection = state.connection.lock().await;
let conn = connection.as_mut().ok_or("Not connected")?;
conn.seek(position_secs).await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_dlna_get_position(
state: State<'_, DlnaState>,
) -> Result<crate::cast::DlnaPositionInfo, String> {
let connection = state.connection.lock().await;
let conn = connection
.as_ref()
.ok_or_else(|| "Not connected".to_string())?;
conn.get_position_info().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_dlna_set_volume(volume: f32, state: State<'_, DlnaState>) -> Result<(), String> {
let mut connection = state.connection.lock().await;
let conn = connection.as_mut().ok_or("Not connected")?;
conn.set_volume(volume).await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_clear_offline_cache(
cache_state: State<'_, OfflineCacheState>,
library_state: State<'_, LibraryState>,
) -> Result<(), String> {
let paths = {
let guard = cache_state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.clear_all()?
};
for path in paths {
let p = std::path::Path::new(&path);
if p.exists() {
let _ = std::fs::remove_file(p);
}
}
let cache_dir = cache_state.cache_dir.read().unwrap().clone();
let tracks_dir = cache_dir.join("tracks");
if tracks_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&tracks_dir) {
for entry in entries.flatten() {
let _ = std::fs::remove_file(entry.path());
}
}
}
if let Ok(entries) = std::fs::read_dir(&cache_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name != "tracks" && !name.ends_with(".db") && !name.ends_with(".db-journal") {
let _ = std::fs::remove_dir_all(&path);
}
}
}
}
let guard = library_state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.remove_all_qobuz_cached_tracks()
.map_err(|e| format!("Failed to remove cached tracks from library: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn v2_library_remove_folder(
path: String,
state: State<'_, LibraryState>,
) -> Result<(), String> {
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.remove_folder(&path).map_err(|e| e.to_string())?;
db.delete_tracks_in_folder(&path)
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn v2_library_check_folder_accessible(path: String) -> Result<bool, String> {
log::info!("[V2] library_check_folder_accessible {}", path);
let path_ref = std::path::Path::new(&path);
if !path_ref.exists() {
return Ok(false);
}
// Avoid UI stalls on slow/unresponsive mounts.
let path_clone = path.clone();
let check_result = tokio::time::timeout(
std::time::Duration::from_secs(6),
tokio::task::spawn_blocking(move || {
std::fs::read_dir(std::path::Path::new(&path_clone)).is_ok()
}),
)
.await;
match check_result {
Ok(Ok(accessible)) => Ok(accessible),
Ok(Err(_)) => {
log::warn!(
"[V2] Failed to spawn blocking task for folder check: {}",
path
);
Ok(false)
}
Err(_) => {
// Mounted-but-slow network shares can timeout but still be usable.
let exists = std::path::Path::new(&path).exists();
log::warn!(
"[V2] Timeout checking folder accessibility: {} (exists={})",
path,
exists
);
Ok(exists)
}
}
}
#[tauri::command]
pub async fn v2_library_clear_artwork_cache() -> Result<u64, String> {
let artwork_dir = get_artwork_cache_dir();
if !artwork_dir.exists() {
return Ok(0);
}
let mut cleared = 0u64;
if let Ok(entries) = std::fs::read_dir(&artwork_dir) {
for entry in entries.flatten() {
if let Ok(meta) = entry.metadata() {
if meta.is_file() {
cleared += meta.len();
let _ = std::fs::remove_file(entry.path());
}
}
}
}
Ok(cleared)
}
#[tauri::command]
pub async fn v2_library_clear_thumbnails_cache() -> Result<u64, String> {
let size_before = thumbnails::get_cache_size().unwrap_or(0);
thumbnails::clear_thumbnails().map_err(|e| e.to_string())?;
Ok(size_before)
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_library_get_thumbnail(artworkPath: String) -> Result<String, String> {
log::debug!("Command: library_get_thumbnail for {}", artworkPath);
let source_path = std::path::PathBuf::from(&artworkPath);
if !source_path.exists() {
return Err(format!("Artwork file not found: {}", artworkPath));
}
let thumbnail_path =
thumbnails::get_or_generate_thumbnail(&source_path).map_err(|e| e.to_string())?;
Ok(thumbnail_path.to_string_lossy().to_string())
}
#[tauri::command]
pub async fn v2_library_get_thumbnails_cache_size() -> Result<u64, String> {
log::debug!("Command: library_get_thumbnails_cache_size");
thumbnails::get_cache_size().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_library_get_scan_progress(
library_state: State<'_, LibraryState>,
) -> Result<ScanProgress, String> {
let progress = library_state.scan_progress.lock().await;
Ok(progress.clone())
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_library_get_tracks_by_ids(
trackIds: Vec<i64>,
library_state: State<'_, LibraryState>,
) -> Result<Vec<LocalTrack>, String> {
log::info!(
"Command: library_get_tracks_by_ids ({} tracks)",
trackIds.len()
);
let guard__ = library_state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
let mut tracks = Vec::new();
for track_id in trackIds {
if let Some(track) = db.get_track(track_id).map_err(|e| e.to_string())? {
tracks.push(track);
}
}
Ok(tracks)
}
#[tauri::command]
pub async fn v2_library_play_track(
track_id: i64,
library_state: State<'_, LibraryState>,
ephemeral_state: State<'_, crate::ephemeral_library::EphemeralLibraryState>,
bridge: State<'_, CoreBridgeState>,
offline_cache: State<'_, crate::offline_cache::OfflineCacheState>,
app_state: State<'_, AppState>,
app_handle: tauri::AppHandle,
runtime: State<'_, RuntimeManagerState>,
) -> Result<(), String> {
runtime
.manager()
.check_requirements(CommandRequirement::RequiresUserSession)
.await
.map_err(|e| e.to_string())?;
// Track ids at or above EPHEMERAL_ID_FLOOR belong to the ephemeral
// library (an ad-hoc folder the user opened without persisting it
// to local_tracks). Resolve to the in-memory cache and skip every
// DB-bound branch below — ephemeral tracks are never qobuz_download,
// never offline-cached, never have a CUE pointer. Just read the
// file and hand it to the player. The high-positive id range keeps
// the existing u64-typed queue/context commands happy while still
// being unambiguous (DB autoincrement ids never reach 2^48).
if track_id >= crate::ephemeral_library::EPHEMERAL_ID_FLOOR {
let track = ephemeral_state
.get_track(track_id)
.ok_or_else(|| format!("Ephemeral track {} not found (session may have been cleared)", track_id))?;
let file_path = std::path::Path::new(&track.file_path);
if !file_path.exists() {
return Err(format!("Ephemeral file not found: {}", track.file_path));
}
let audio_data =
std::fs::read(file_path).map_err(|e| format!("Failed to read ephemeral file: {}", e))?;
let bridge = bridge.get().await;
bridge
.player()
.play_data(audio_data, track_id as u64)
.map_err(|e| format!("Failed to play ephemeral track: {}", e))?;
// CUE-derived tracks share a single audio file (e.g. one big
// FLAC for the whole album); per-track playback works by
// seeking into the right offset after handing the data to the
// player. Mirrors the regular local-file branch below.
if let Some(start_secs) = track.cue_start_secs {
let start_pos = start_secs as u64;
if start_pos > 0 {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
bridge
.player()
.seek(start_pos)
.map_err(|e| format!("Failed to seek into CUE track: {}", e))?;
}
}
return Ok(());
}
let track = {
let guard = library_state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.get_track(track_id)
.map_err(|e| e.to_string())?
.ok_or_else(|| "Track not found".to_string())?
};
// Qobuz-cached offline tracks are authoritative in the offline-cache
// DB, not the library index. The library row for these tracks carries
// metadata only (title/artist/album) plus a display path; the library's
// `file_path` for v2 entries is a track directory that is not playable
// directly. So for source='qobuz_download' we always resolve through
// the offline-cache DB — cache_format tells us whether to decrypt the
// CMAF bundle or to read a plain-FLAC v1 file.
//
// NOTE: `track_id` here is the library row id (autoincrement), NOT the
// Qobuz track id. The offline-cache DB is keyed by Qobuz track id. The
// library row carries the Qobuz id in `qobuz_track_id`.
let is_qobuz_cached = track.source.as_deref() == Some("qobuz_download");
if is_qobuz_cached {
let qobuz_track_id = match track.qobuz_track_id {
Some(id) => id,
None => {
return Err(format!(
"Library row {} is marked qobuz_download but has no qobuz_track_id",
track_id
));
}
};
let bundle_row = {
let guard = offline_cache.db.lock().await;
guard
.as_ref()
.and_then(|db| db.get_cmaf_bundle(qobuz_track_id as u64).ok().flatten())
};
match bundle_row {
Some(row) if row.cache_format == 2 => {
let cache_path = offline_cache.get_cache_path();
// spawn_blocking via the ui-events helper so the track row
// shows an "unlocking" animation while decrypt runs.
// Display id = library row id (what the UI renders by),
// CMAF id = qobuz track id (what the bundle is keyed by).
let audio_data =
crate::offline_cache::playback::load_cmaf_bundle_with_ui_events(
&app_handle,
track_id as u64,
qobuz_track_id as u64,
row.clone(),
cache_path,
)
.await
.ok_or_else(|| {
format!(
"Offline CMAF bundle for Qobuz track {} is present but failed to decrypt",
qobuz_track_id
)
})?;
// Warm L1 so subsequent access (replay, gapless) is instant.
// Keyed by the library row id so Library replay hits; the
// offline cache DB itself is keyed by Qobuz id and is
// consulted separately up above.
app_state.audio_cache.insert(track_id as u64, audio_data.clone());
let bridge = bridge.get().await;
// IMPORTANT: play_data gets the LIBRARY track_id (the row
// id the frontend already tracks), NOT the Qobuz track id.
// Every piece of UI state — currently-playing card,
// seekbar position updates, queue auto-advance detection —
// keys off the id the frontend sent in. Using the Qobuz
// id here silently desynced the player from the UI:
// backend reports "playing 95787326" while the frontend
// waits for updates on 542 → seekbar never ticks, track
// never auto-advances, queue panics and starts calling
// next_track every second.
bridge
.player()
.play_data(audio_data, track_id as u64)
.map_err(|e| format!("Failed to play CMAF offline bundle: {}", e))?;
if let Some(start_secs) = track.cue_start_secs {
let start_pos = start_secs as u64;
if start_pos > 0 {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
bridge
.player()
.seek(start_pos)
.map_err(|e| format!("Failed to seek: {}", e))?;
}
}
return Ok(());
}
Some(row) => {
// cache_format=1 (legacy plain FLAC). The authoritative path
// is the offline-cache row's `segments_path` field (v1 stored
// the FLAC file path there), not the library index file_path
// (which for qobuz_download entries is now a display-only
// value that may be a directory).
let file_path = std::path::Path::new(&row.segments_path);
if !file_path.exists() {
return Err(format!(
"Offline cache file missing for Qobuz track {}: {}",
qobuz_track_id, row.segments_path
));
}
let audio_data = std::fs::read(file_path)
.map_err(|e| format!("Failed to read v1 offline file: {}", e))?;
let bridge = bridge.get().await;
bridge
.player()
.play_data(audio_data, track_id as u64)
.map_err(|e| format!("Failed to play: {}", e))?;
return Ok(());
}
None => {
// Orphan: library row says qobuz_download but offline cache
// has no record. Either the cache was partially wiped or
// there's a corruption — surface the error instead of
// trying to read whatever the library file_path says (which
// for v2 is a directory and blows up with os error 21).
return Err(format!(
"Offline cache entry for Qobuz track {} is missing (library index is stale)",
qobuz_track_id
));
}
}
}
// Regular user local library files (FLAC/MP3 owned by the user,
// not Qobuz-cached). Read the file path from the library row.
let file_path = std::path::Path::new(&track.file_path);
if !file_path.exists() {
return Err(format!("File not found: {}", track.file_path));
}
let audio_data = std::fs::read(file_path).map_err(|e| format!("Failed to read file: {}", e))?;
let bridge = bridge.get().await;
bridge
.player()
.play_data(audio_data, track_id as u64)
.map_err(|e| format!("Failed to play: {}", e))?;
if let Some(start_secs) = track.cue_start_secs {
let start_pos = start_secs as u64;
if start_pos > 0 {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
bridge
.player()
.seek(start_pos)
.map_err(|e| format!("Failed to seek: {}", e))?;
}
}
Ok(())
}
#[tauri::command]
pub async fn v2_playlist_set_sort(
playlist_id: u64,
sort_by: String,
sort_order: String,
state: State<'_, LibraryState>,
) -> Result<(), String> {
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.update_playlist_sort(playlist_id, &sort_by, &sort_order)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_playlist_set_artwork(
playlist_id: u64,
artwork_path: Option<String>,
state: State<'_, LibraryState>,
) -> Result<(), String> {
let final_path = if let Some(source_path) = artwork_path {
let artwork_dir = get_artwork_cache_dir();
let source = std::path::Path::new(&source_path);
if !source.exists() {
return Err(format!("Source image does not exist: {}", source_path));
}
let extension = source.extension().and_then(|e| e.to_str()).unwrap_or("jpg");
let filename = format!(
"playlist_{}_{}.{}",
playlist_id,
chrono::Utc::now().timestamp(),
extension
);
let dest_path = artwork_dir.join(filename);
std::fs::copy(source, &dest_path).map_err(|e| format!("Failed to copy artwork: {}", e))?;
Some(dest_path.to_string_lossy().to_string())
} else {
None
};
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.update_playlist_artwork(playlist_id, final_path.as_deref())
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_playlist_add_local_track(
playlist_id: u64,
local_track_id: i64,
position: i32,
state: State<'_, LibraryState>,
) -> Result<(), String> {
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.add_local_track_to_playlist(playlist_id, local_track_id, position)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_playlist_remove_local_track(
playlist_id: u64,
local_track_id: i64,
state: State<'_, LibraryState>,
) -> Result<(), String> {
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.remove_local_track_from_playlist(playlist_id, local_track_id)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_playlist_set_hidden(
playlist_id: u64,
hidden: bool,
state: State<'_, LibraryState>,
) -> Result<(), String> {
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.set_playlist_hidden(playlist_id, hidden)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_playlist_set_favorite(
playlist_id: u64,
favorite: bool,
state: State<'_, LibraryState>,
) -> Result<(), String> {
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.set_playlist_favorite(playlist_id, favorite)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_playlist_reorder(
playlist_ids: Vec<u64>,
state: State<'_, LibraryState>,
) -> Result<(), String> {
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.reorder_playlists(&playlist_ids)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_playlist_init_custom_order(
playlist_id: u64,
track_ids: Vec<(i64, bool)>,
state: State<'_, LibraryState>,
) -> Result<(), String> {
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.init_playlist_custom_order(playlist_id, &track_ids)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_playlist_set_custom_order(
playlist_id: u64,
orders: Vec<(i64, bool, i32)>,
state: State<'_, LibraryState>,
) -> Result<(), String> {
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.set_playlist_custom_order(playlist_id, &orders)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_playlist_move_track(
playlist_id: u64,
track_id: i64,
is_local: bool,
new_position: i32,
state: State<'_, LibraryState>,
) -> Result<(), String> {
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.move_playlist_track(playlist_id, track_id, is_local, new_position)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_library_set_album_artwork(
album_group_key: String,
artwork_path: String,
state: State<'_, LibraryState>,
) -> Result<String, String> {
if album_group_key.is_empty() {
return Err("Album group key is required".to_string());
}
let source_path = std::path::Path::new(&artwork_path);
if !source_path.is_file() {
return Err("Artwork file not found".to_string());
}
let artwork_cache = get_artwork_cache_dir();
let cached_path = MetadataExtractor::cache_artwork_file(source_path, &artwork_cache)
.ok_or_else(|| "Failed to cache artwork file".to_string())?;
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
// User-initiated set-album-artwork command: writing one cover to all
// tracks in the group is the explicit user intent here.
#[allow(deprecated)]
db.update_album_group_artwork(&album_group_key, &cached_path)
.map_err(|e| e.to_string())?;
Ok(cached_path)
}
#[tauri::command]
pub async fn v2_library_set_album_hidden(
album_group_key: String,
hidden: bool,
state: State<'_, LibraryState>,
) -> Result<(), String> {
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.set_album_hidden(&album_group_key, hidden)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_delete_playlist_folder(
id: String,
state: State<'_, LibraryState>,
) -> Result<(), String> {
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.delete_playlist_folder(&id).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_reorder_playlist_folders(
folder_ids: Vec<String>,
state: State<'_, LibraryState>,
) -> Result<(), String> {
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.reorder_playlist_folders(&folder_ids)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_move_playlist_to_folder(
playlist_id: u64,
folder_id: Option<String>,
state: State<'_, LibraryState>,
) -> Result<(), String> {
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.move_playlist_to_folder(playlist_id, folder_id.as_deref())
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_lyrics_clear_cache(state: State<'_, LyricsState>) -> Result<(), String> {
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.clear().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_musicbrainz_get_cache_stats(
state: State<'_, MusicBrainzV2State>,
) -> Result<qbz_integrations::musicbrainz::cache::CacheStats, String> {
let cache_opt = state.cache.lock().await;
let cache = cache_opt
.as_ref()
.ok_or("No active session - please log in")?;
cache.get_stats()
}
#[tauri::command]
pub async fn v2_musicbrainz_clear_cache(
state: State<'_, MusicBrainzV2State>,
) -> Result<(), String> {
let cache_opt = state.cache.lock().await;
let cache = cache_opt
.as_ref()
.ok_or("No active session - please log in")?;
cache.clear_all()
}
#[tauri::command]
pub fn v2_set_show_partial_playlists(
enabled: bool,
state: State<'_, OfflineState>,
) -> Result<(), String> {
let guard = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_show_partial_playlists(enabled)
}
#[tauri::command]
pub fn v2_set_allow_cast_while_offline(
enabled: bool,
state: State<'_, OfflineState>,
) -> Result<(), String> {
let guard = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_allow_cast_while_offline(enabled)
}
#[tauri::command]
pub fn v2_set_allow_immediate_scrobbling(
enabled: bool,
state: State<'_, OfflineState>,
) -> Result<(), String> {
let guard = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_allow_immediate_scrobbling(enabled)
}
#[tauri::command]
pub fn v2_set_allow_accumulated_scrobbling(
enabled: bool,
state: State<'_, OfflineState>,
) -> Result<(), String> {
let guard = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_allow_accumulated_scrobbling(enabled)
}
#[tauri::command]
pub fn v2_set_show_network_folders_in_manual_offline(
enabled: bool,
state: State<'_, OfflineState>,
) -> Result<(), String> {
let guard = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.set_show_network_folders_in_manual_offline(enabled)
}
#[tauri::command]
pub async fn v2_get_offline_status(
state: State<'_, OfflineState>,
) -> Result<crate::offline::OfflineStatus, String> {
let settings = {
let guard__ = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard__
.as_ref()
.ok_or("No active session - please log in")?;
store.get_settings()?
};
// If manual offline mode is enabled, return immediately
if settings.manual_offline_mode {
return Ok(crate::offline::OfflineStatus {
is_offline: true,
reason: Some(crate::offline::OfflineReason::ManualOverride),
manual_mode_enabled: true,
});
}
// Fix E (issue #467): positive liveness override. If audio segments were
// flowing within the liveness window we are, by definition, online —
// regardless of what the connectivity probe says. Heavy Hi-Res tracks
// (long Dream Theater / Yes) saturate the link and can time the probe
// out; active streaming is the strongest possible "online" signal.
const LIVENESS_WINDOW_SECS: u64 = 45;
if let Some(secs) = qbz_audio::network_throttle::state().seconds_since_download() {
if secs <= LIVENESS_WINDOW_SECS {
crate::offline::reset_connectivity_failures();
return Ok(crate::offline::OfflineStatus {
is_offline: false,
reason: None,
manual_mode_enabled: false,
});
}
}
// Check network connectivity (cheap concurrent probe) and apply
// consecutive-failure hysteresis (Fix A, issue #467): a single failed poll
// must not flip offline — only OFFLINE_FAILURE_THRESHOLD consecutive
// failures do.
let has_network = crate::offline::check_network_connectivity().await;
let failures = crate::offline::record_connectivity_result(has_network);
if !has_network && failures >= crate::offline::OFFLINE_FAILURE_THRESHOLD {
log::info!(
"[offline] Declaring offline after {} consecutive connectivity failures",
failures
);
return Ok(crate::offline::OfflineStatus {
is_offline: true,
reason: Some(crate::offline::OfflineReason::NoNetwork),
manual_mode_enabled: false,
});
}
if !has_network {
log::info!(
"[offline] Connectivity probe failed ({}/{}); staying online (hysteresis)",
failures,
crate::offline::OFFLINE_FAILURE_THRESHOLD
);
}
// Online, or a transient failure still under the threshold: stay online.
Ok(crate::offline::OfflineStatus {
is_offline: false,
reason: None,
manual_mode_enabled: false,
})
}
#[tauri::command]
pub fn v2_get_offline_settings(
state: State<'_, OfflineState>,
) -> Result<crate::offline::OfflineSettings, String> {
let guard__ = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard__
.as_ref()
.ok_or("No active session - please log in")?;
store.get_settings()
}
#[tauri::command]
pub async fn v2_set_manual_offline(
enabled: bool,
state: State<'_, OfflineState>,
audio_state: State<'_, crate::config::audio_settings::AudioSettingsState>,
app_handle: tauri::AppHandle,
) -> Result<crate::offline::OfflineStatus, String> {
use tauri::Emitter;
// 1) Persist the manual offline flag first so any concurrent reads
// see the new mode immediately.
let (was_offline, stream_first_before): (bool, Option<bool>) = {
let guard__ = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard__
.as_ref()
.ok_or("No active session - please log in")?;
let prev = store
.get_settings()
.map(|s| s.manual_offline_mode)
.unwrap_or(false);
let snapshot = store
.get_pre_offline_stream_first_track()
.unwrap_or(None);
store.set_manual_offline_mode(enabled)?;
(prev, snapshot)
};
// 2) Coordinate the stream_first_track snapshot/restore. This is best-
// effort — if the audio store isn't initialized yet (pre-login), we
// just skip; the offline flag itself has been persisted.
if enabled && !was_offline {
// Entering offline mode: stash current value and force to false.
let current = {
let guard = audio_state
.store
.lock()
.map_err(|e| format!("Audio settings lock error: {}", e))?;
guard
.as_ref()
.and_then(|s| s.get_settings().ok())
.map(|s| s.stream_first_track)
};
if let Some(current_value) = current {
{
let guard = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
if let Some(store) = guard.as_ref() {
let _ = store.set_pre_offline_stream_first_track(Some(current_value));
}
}
if current_value {
let guard = audio_state
.store
.lock()
.map_err(|e| format!("Audio settings lock error: {}", e))?;
if let Some(store) = guard.as_ref() {
let _ = store.set_stream_first_track(false);
log::info!(
"[Offline] stream_first_track snapshot={}; forced to false while offline",
current_value
);
}
}
}
} else if !enabled && was_offline {
// Exiting offline mode: restore snapshot if we have one.
if let Some(snapshot_value) = stream_first_before {
{
let guard = audio_state
.store
.lock()
.map_err(|e| format!("Audio settings lock error: {}", e))?;
if let Some(store) = guard.as_ref() {
let _ = store.set_stream_first_track(snapshot_value);
log::info!(
"[Offline] restored stream_first_track to {}",
snapshot_value
);
}
}
{
let guard = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
if let Some(store) = guard.as_ref() {
let _ = store.set_pre_offline_stream_first_track(None);
}
}
}
}
// 3) Get updated status
let status = v2_get_offline_status(state).await?;
// 4) Emit events. offline-status-changed for the offline store;
// audio-settings-changed so the SettingsView reloads its local
// state of stream_first_track (we may have just mutated it).
let _ = app_handle.emit("offline-status-changed", &status);
let _ = app_handle.emit("audio-settings-changed", ());
Ok(status)
}
#[tauri::command]
pub async fn v2_check_network() -> bool {
crate::offline::check_network_connectivity().await
}
#[tauri::command]
pub fn v2_add_tracks_to_pending_playlist(
pending_id: i64,
qobuz_track_ids: Vec<u64>,
local_track_paths: Vec<String>,
state: State<'_, OfflineState>,
) -> Result<(), String> {
let guard = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.add_tracks_to_pending_playlist(pending_id, &qobuz_track_ids, &local_track_paths)
}
#[tauri::command]
pub fn v2_update_pending_playlist_qobuz_id(
pending_id: i64,
qobuz_playlist_id: u64,
state: State<'_, OfflineState>,
) -> Result<(), String> {
let guard = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.update_qobuz_playlist_id(pending_id, qobuz_playlist_id)
}
#[tauri::command]
pub fn v2_mark_pending_playlist_synced(
pending_id: i64,
qobuz_playlist_id: u64,
state: State<'_, OfflineState>,
) -> Result<(), String> {
let guard = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.mark_playlist_synced(pending_id, qobuz_playlist_id)
}
#[tauri::command]
pub fn v2_delete_pending_playlist(
pending_id: i64,
state: State<'_, OfflineState>,
) -> Result<(), String> {
let guard = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.delete_pending_playlist(pending_id)
}
#[tauri::command]
pub fn v2_mark_scrobbles_sent(ids: Vec<i64>, state: State<'_, OfflineState>) -> Result<(), String> {
let guard = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard.as_ref().ok_or("No active session - please log in")?;
store.mark_scrobbles_sent(&ids)
}
#[tauri::command]
pub fn v2_get_pending_playlists(
state: State<'_, OfflineState>,
) -> Result<Vec<crate::offline::PendingPlaylist>, String> {
let guard__ = state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = guard__
.as_ref()
.ok_or("No active session - please log in")?;
store.get_pending_playlists()
}
#[tauri::command]
pub async fn v2_remove_cached_track(
track_id: u64,
cache_state: State<'_, OfflineCacheState>,
library_state: State<'_, LibraryState>,
) -> Result<(), String> {
{
let guard = cache_state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
if let Some(file_path) = db.delete_track(track_id)? {
let path = std::path::Path::new(&file_path);
if path.exists() {
let _ = std::fs::remove_file(path);
}
}
}
let guard = library_state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
let _ = db.remove_qobuz_cached_track(track_id);
Ok(())
}
#[tauri::command]
pub async fn v2_get_cached_tracks(
cache_state: State<'_, OfflineCacheState>,
) -> Result<Vec<crate::offline_cache::CachedTrackInfo>, String> {
let guard = cache_state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.get_all_tracks()
}
#[tauri::command]
pub async fn v2_get_offline_cache_stats(
cache_state: State<'_, OfflineCacheState>,
) -> Result<crate::offline_cache::OfflineCacheStats, String> {
let limit = *cache_state.limit_bytes.lock().await;
let guard = cache_state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.get_stats(&cache_state.get_cache_path(), limit)
}
#[tauri::command]
pub async fn v2_set_offline_cache_limit(
limit_mb: Option<u64>,
cache_state: State<'_, OfflineCacheState>,
offline_state: State<'_, OfflineState>,
) -> Result<(), String> {
let limit_bytes = limit_mb.map(|mb| mb * 1024 * 1024);
// Persist to offline_settings.db so the limit survives across restarts.
{
let store_guard = offline_state
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
let store = store_guard
.as_ref()
.ok_or("No active session - please log in")?;
store.set_cache_limit_bytes(limit_bytes)?;
}
// Update in-memory limit so subsequent reads / pre-flight checks see the
// new value without waiting for a session restart.
let mut limit = cache_state.limit_bytes.lock().await;
*limit = limit_bytes;
Ok(())
}
#[tauri::command]
pub async fn v2_open_offline_cache_folder(
cache_state: State<'_, OfflineCacheState>,
) -> Result<(), String> {
let path = cache_state.cache_dir.read().unwrap().clone();
std::fs::create_dir_all(&path)
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
open::that(&path).map_err(|e| format!("Failed to open folder: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn v2_open_album_folder(
album_id: String,
cache_state: State<'_, OfflineCacheState>,
) -> Result<(), String> {
let guard = cache_state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
let tracks = db.get_all_tracks()?;
let album_tracks: Vec<_> = tracks
.into_iter()
.filter(|t| t.album_id.as_deref() == Some(&album_id))
.collect();
if album_tracks.is_empty() {
return Err("No cached tracks found for this album".to_string());
}
let file_path = db
.get_file_path(album_tracks[0].track_id)?
.ok_or_else(|| "Track file path not found".to_string())?;
let album_dir = std::path::Path::new(&file_path)
.parent()
.ok_or_else(|| "Could not determine album folder".to_string())?;
open::that(album_dir).map_err(|e| format!("Failed to open folder: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn v2_open_track_folder(
track_id: u64,
cache_state: State<'_, OfflineCacheState>,
) -> Result<(), String> {
let guard = cache_state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
let file_path = db
.get_file_path(track_id)?
.ok_or_else(|| "Track file path not found - track may not be cached".to_string())?;
let track_dir = std::path::Path::new(&file_path)
.parent()
.ok_or_else(|| "Could not determine track folder".to_string())?;
open::that(track_dir).map_err(|e| format!("Failed to open folder: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn v2_lastfm_open_auth_url(url: String) -> Result<(), String> {
open::that(&url).map_err(|e| format!("Failed to open browser: {}", e))
}
#[tauri::command]
pub async fn v2_lastfm_set_credentials(
api_key: String,
api_secret: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let mut client = state.lastfm.lock().await;
client.set_credentials(api_key, api_secret);
Ok(())
}
#[tauri::command]
pub async fn v2_reco_log_event(
event: RecoEventInput,
state: State<'_, RecoState>,
) -> Result<(), String> {
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.insert_event(&event)
}
#[tauri::command]
pub async fn v2_reco_train_scores(
lookback_days: Option<i64>,
half_life_days: Option<f64>,
max_events: Option<u32>,
max_per_type: Option<u32>,
state: State<'_, RecoState>,
) -> Result<(), String> {
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
let lookback_days = lookback_days.unwrap_or(90);
let half_life_days = half_life_days.unwrap_or(21.0);
let max_events = max_events.unwrap_or(5000);
let max_per_type = max_per_type.unwrap_or(200);
let now_ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let since_ts = now_ts.saturating_sub(lookback_days * 86_400);
let mut guard = state.db.lock().await;
let db = guard.as_mut().ok_or("No active session - please log in")?;
let events = db.get_events_since(since_ts, Some(max_events))?;
let decay_factor = |age_secs: i64| -> f64 {
if half_life_days <= 0.0 {
return 1.0;
}
let half_life_secs = half_life_days * 86_400.0;
let exponent = age_secs as f64 / half_life_secs;
0.5_f64.powf(exponent)
};
let event_weight = |event_type: &str| -> f64 {
match event_type {
"play" => 1.0,
"favorite" => 3.0,
"playlist_add" => 1.2,
_ => 1.0,
}
};
let item_weight = |item_type: &str, primary: bool| -> f64 {
if primary {
return 1.0;
}
match item_type {
"album" => 0.7,
"artist" => 0.5,
"track" => 0.85,
_ => 0.6,
}
};
let build_scores = |favorites_only: bool| {
let mut tracks: HashMap<u64, f64> = HashMap::new();
let mut albums: HashMap<String, f64> = HashMap::new();
let mut artists: HashMap<u64, f64> = HashMap::new();
for event in &events {
if favorites_only && event.event_type != "favorite" {
continue;
}
let age_secs = (now_ts - event.created_at).max(0);
let base_weight = event_weight(&event.event_type) * decay_factor(age_secs);
if let Some(track_id) = event.track_id {
let weight = base_weight * item_weight("track", event.item_type == "track");
*tracks.entry(track_id).or_insert(0.0) += weight;
}
if let Some(album_id) = event.album_id.as_ref() {
let weight = base_weight * item_weight("album", event.item_type == "album");
*albums.entry(album_id.clone()).or_insert(0.0) += weight;
}
if let Some(artist_id) = event.artist_id {
let weight = base_weight * item_weight("artist", event.item_type == "artist");
*artists.entry(artist_id).or_insert(0.0) += weight;
}
}
(tracks, albums, artists)
};
let build_track_entries = |scores: HashMap<u64, f64>| {
let mut entries: Vec<(u64, f64)> = scores.into_iter().collect();
entries.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
entries
.into_iter()
.take(max_per_type as usize)
.map(|(track_id, score)| crate::reco_store::db::RecoScoreEntry {
track_id: Some(track_id),
album_id: None,
artist_id: None,
score,
})
.collect::<Vec<_>>()
};
let build_album_entries = |scores: HashMap<String, f64>| {
let mut entries: Vec<(String, f64)> = scores.into_iter().collect();
entries.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
entries
.into_iter()
.take(max_per_type as usize)
.map(|(album_id, score)| crate::reco_store::db::RecoScoreEntry {
track_id: None,
album_id: Some(album_id),
artist_id: None,
score,
})
.collect::<Vec<_>>()
};
let build_artist_entries = |scores: HashMap<u64, f64>| {
let mut entries: Vec<(u64, f64)> = scores.into_iter().collect();
entries.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
entries
.into_iter()
.take(max_per_type as usize)
.map(|(artist_id, score)| crate::reco_store::db::RecoScoreEntry {
track_id: None,
album_id: None,
artist_id: Some(artist_id),
score,
})
.collect::<Vec<_>>()
};
let (all_tracks, all_albums, all_artists) = build_scores(false);
let (fav_tracks, fav_albums, fav_artists) = build_scores(true);
db.replace_scores("all", "track", &build_track_entries(all_tracks))?;
db.replace_scores("all", "album", &build_album_entries(all_albums))?;
db.replace_scores("all", "artist", &build_artist_entries(all_artists))?;
db.replace_scores("favorite", "track", &build_track_entries(fav_tracks))?;
db.replace_scores("favorite", "album", &build_album_entries(fav_albums))?;
db.replace_scores("favorite", "artist", &build_artist_entries(fav_artists))?;
Ok(())
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_reco_get_home(
limitRecentAlbums: Option<u32>,
limitContinueTracks: Option<u32>,
limitTopArtists: Option<u32>,
limitFavorites: Option<u32>,
state: State<'_, RecoState>,
) -> Result<HomeSeeds, String> {
let limit_recent_albums = limitRecentAlbums.unwrap_or(12);
let limit_continue_tracks = limitContinueTracks.unwrap_or(10);
let limit_top_artists = limitTopArtists.unwrap_or(10);
let limit_favorites = limitFavorites.unwrap_or(12);
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
let recently_played_album_ids = db.get_recent_album_ids(limit_recent_albums)?;
let continue_listening_track_ids = db.get_recent_track_ids(limit_continue_tracks)?;
let top_artist_ids = db.get_top_artist_ids(limit_top_artists)?;
let favorite_album_ids = db.get_favorite_album_ids(limit_favorites)?;
let favorite_track_ids = db.get_favorite_track_ids(limit_favorites)?;
Ok(HomeSeeds {
recently_played_album_ids,
continue_listening_track_ids,
top_artist_ids,
favorite_album_ids,
favorite_track_ids,
})
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_reco_get_home_ml(
limitRecentAlbums: Option<u32>,
limitContinueTracks: Option<u32>,
limitTopArtists: Option<u32>,
limitFavorites: Option<u32>,
state: State<'_, RecoState>,
) -> Result<HomeSeeds, String> {
let limit_recent_albums = limitRecentAlbums.unwrap_or(12);
let limit_continue_tracks = limitContinueTracks.unwrap_or(10);
let limit_top_artists = limitTopArtists.unwrap_or(10);
let limit_favorites = limitFavorites.unwrap_or(12);
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
crate::reco_store::helpers::get_home_seeds_internal(
db,
limit_recent_albums,
limit_continue_tracks,
limit_top_artists,
limit_favorites,
)
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_reco_get_home_resolved(
limitRecentAlbums: Option<u32>,
limitContinueTracks: Option<u32>,
limitTopArtists: Option<u32>,
limitFavorites: Option<u32>,
reco_state: State<'_, RecoState>,
app_state: State<'_, AppState>,
cache_state: State<'_, ApiCacheState>,
) -> Result<HomeResolved, String> {
use rand::seq::SliceRandom;
use std::collections::HashMap;
let limit_recent_albums = limitRecentAlbums.unwrap_or(12);
let limit_continue_tracks = limitContinueTracks.unwrap_or(10);
let limit_top_artists = limitTopArtists.unwrap_or(10);
let limit_favorites = limitFavorites.unwrap_or(12);
// Step 1: Get seeds (IDs) from reco DB
let seeds = {
let guard__ = reco_state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
crate::reco_store::helpers::get_home_seeds_internal(
db,
limit_recent_albums,
limit_continue_tracks,
limit_top_artists,
limit_favorites,
)?
};
// Step 2: Collect all unique IDs needed
// For favorite albums: merge favorite album IDs + album IDs from favorite tracks
// (favorite tracks → their albums is done after track resolution)
let all_track_ids: Vec<u64> = {
let mut ids = seeds.continue_listening_track_ids.clone();
ids.extend(&seeds.favorite_track_ids);
ids.sort_unstable();
ids.dedup();
ids
};
let all_artist_ids: Vec<u64> = seeds.top_artist_ids.iter().map(|s| s.artist_id).collect();
// Build play_count map for artists
let artist_play_counts: HashMap<u64, u32> = seeds
.top_artist_ids
.iter()
.map(|s| (s.artist_id, s.play_count))
.collect();
// Step 3: Resolve all entity types in parallel.
// Recent albums, favorite albums, tracks, and artists are all independent.
let recent_albums_fut = crate::reco_store::helpers::resolve_albums(
&seeds.recently_played_album_ids,
&reco_state,
&app_state,
&cache_state,
);
let favorite_albums_fut = crate::reco_store::helpers::resolve_albums(
&seeds.favorite_album_ids,
&reco_state,
&app_state,
&cache_state,
);
let tracks_fut = crate::reco_store::helpers::resolve_tracks(
&all_track_ids,
&reco_state,
&app_state,
&cache_state,
);
let artists_fut = crate::reco_store::helpers::resolve_artists(
&all_artist_ids,
&artist_play_counts,
&reco_state,
&app_state,
&cache_state,
);
let (recently_played_albums, resolved_favorites, all_tracks, top_artists) = tokio::try_join!(
recent_albums_fut,
favorite_albums_fut,
tracks_fut,
artists_fut
)?;
// Build track lookup
let track_map: HashMap<u64, &crate::reco_store::TrackDisplayMeta> =
all_tracks.iter().map(|tr| (tr.id, tr)).collect();
let continue_listening_tracks: Vec<crate::reco_store::TrackDisplayMeta> = seeds
.continue_listening_track_ids
.iter()
.filter_map(|id| track_map.get(id).map(|tr| (*tr).clone()))
.collect();
// Step 4: "More From Favorites" = discover albums by favorite artists,
// excluding albums the user already has in favorites / recently played.
// 5b: Collect unique artist IDs from favorite albums + favorite tracks
let favorite_artist_ids: Vec<u64> = {
let mut ids = Vec::new();
let mut seen = std::collections::HashSet::new();
for album in &resolved_favorites {
if let Some(aid) = album.artist_id {
if seen.insert(aid) {
ids.push(aid);
}
}
}
for track_id in &seeds.favorite_track_ids {
if let Some(track) = track_map.get(track_id) {
if let Some(aid) = track.artist_id {
if seen.insert(aid) {
ids.push(aid);
}
}
}
}
ids
};
// 5c: Build exclusion set (already-favorited + recently played albums)
let exclusion_set: std::sync::Arc<std::collections::HashSet<String>> = {
let mut set: std::collections::HashSet<String> =
seeds.favorite_album_ids.iter().cloned().collect();
for track_id in &seeds.favorite_track_ids {
if let Some(track) = track_map.get(track_id) {
if let Some(ref album_id) = track.album_id {
if !album_id.is_empty() {
set.insert(album_id.clone());
}
}
}
}
for album_id in &seeds.recently_played_album_ids {
set.insert(album_id.clone());
}
std::sync::Arc::new(set)
};
// 5d: Fetch albums from favorite artists (parallel)
// Shuffle artists so every favorite gets a fair chance, cap 2 albums per artist
const MAX_ALBUMS_PER_ARTIST: usize = 2;
let favorite_albums = if favorite_artist_ids.is_empty() {
Vec::new()
} else {
// Shuffle artists so every favorite gets a fair chance
let shuffled_artists = {
let mut ids = favorite_artist_ids.clone();
ids.shuffle(&mut rand::rng());
ids
};
let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(5));
let client = app_state.client.clone();
let reco_arc = reco_state.db.clone();
let mut handles = Vec::new();
for artist_id in shuffled_artists.iter().take(14) {
let sem = sem.clone();
let client = client.clone();
let reco_arc = reco_arc.clone();
let exclusion = exclusion_set.clone();
let artist_id = *artist_id;
handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await.ok()?;
let artist = {
let c = client.read().await;
c.get_artist_with_pagination_and_locale(artist_id, true, Some(25), None, None)
.await
.ok()?
};
let albums = artist.albums?;
let mut results: Vec<crate::reco_store::AlbumCardMeta> = Vec::new();
for album in &albums.items {
if results.len() >= MAX_ALBUMS_PER_ARTIST {
break;
}
if !exclusion.contains(&album.id) {
let meta = crate::reco_store::helpers::album_to_card_meta(album);
{
let guard__ = reco_arc.lock().await;
if let Some(db) = guard__.as_ref() {
let _ = db.set_album_meta(&meta);
}
}
results.push(meta);
}
}
Some(results)
}));
}
let mut all_albums: Vec<crate::reco_store::AlbumCardMeta> = Vec::new();
for handle in handles {
if let Ok(Some(albums)) = handle.await {
all_albums.extend(albums);
}
}
// Deduplicate, shuffle, and limit
{
let mut seen = std::collections::HashSet::new();
all_albums.retain(|a| seen.insert(a.id.clone()));
}
all_albums.shuffle(&mut rand::rng());
all_albums.truncate(limit_favorites as usize);
all_albums
};
Ok(HomeResolved {
recently_played_albums,
continue_listening_tracks,
top_artists,
favorite_albums,
})
}
/// Get album suggestions (similar albums) from Qobuz /album/suggest API
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_get_album_suggestions(
albumId: String,
limit: Option<u32>,
state: State<'_, AppState>,
blacklist_state: State<'_, BlacklistState>,
runtime: State<'_, RuntimeManagerState>,
) -> Result<Vec<crate::api::models::Album>, String> {
runtime
.manager()
.check_requirements(CommandRequirement::RequiresCoreBridgeAuth)
.await
.map_err(|e| e.to_string())?;
let client = state.client.read().await.clone();
let response = client
.get_album_suggest(&albumId)
.await
.map_err(|e| format!("Failed to get album suggestions: {}", e))?;
let mut albums = response.albums.map(|page| page.items).unwrap_or_default();
// Apply blacklist
albums.retain(|album| !blacklist_state.is_blacklisted(album.artist.id));
let max = limit.unwrap_or(10) as usize;
albums.truncate(max);
Ok(albums)
}
/// Get "forgotten" favorite albums — favorites not played in recent N days
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_reco_get_forgotten_favorites(
limit: Option<u32>,
recencyDays: Option<u32>,
reco_state: State<'_, RecoState>,
app_state: State<'_, AppState>,
cache_state: State<'_, ApiCacheState>,
) -> Result<Vec<crate::reco_store::AlbumCardMeta>, String> {
let guard = reco_state.db.lock().await;
let db = guard.as_ref().ok_or("No active session")?;
let album_ids =
db.get_forgotten_favorite_album_ids(limit.unwrap_or(12), recencyDays.unwrap_or(30))?;
drop(guard);
if album_ids.is_empty() {
return Ok(Vec::new());
}
// Resolve album IDs to metadata using the same 3-tier cache as home
crate::reco_store::helpers::resolve_albums(&album_ids, &reco_state, &app_state, &cache_state)
.await
}
/// Get user's top genres by play count
#[tauri::command]
pub async fn v2_reco_get_top_genres(
limit: Option<u32>,
reco_state: State<'_, RecoState>,
) -> Result<Vec<TopGenre>, String> {
let guard = reco_state.db.lock().await;
let db = guard.as_ref().ok_or("No active session")?;
let genres = db.get_top_genre_ids(limit.unwrap_or(5))?;
Ok(genres
.into_iter()
.map(|(id, name)| TopGenre { id, name })
.collect())
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TopGenre {
pub id: u64,
pub name: String,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct V2LibraryCacheStats {
pub artwork_cache_bytes: u64,
pub thumbnails_cache_bytes: u64,
pub artwork_file_count: usize,
pub thumbnail_file_count: usize,
}
#[tauri::command]
pub async fn v2_library_get_cache_stats() -> Result<V2LibraryCacheStats, String> {
let artwork_dir = get_artwork_cache_dir();
let (artwork_bytes, artwork_count) = if artwork_dir.exists() {
let mut size = 0u64;
let mut count = 0usize;
if let Ok(entries) = std::fs::read_dir(&artwork_dir) {
for entry in entries.flatten() {
if let Ok(meta) = entry.metadata() {
if meta.is_file() {
size += meta.len();
count += 1;
}
}
}
}
(size, count)
} else {
(0, 0)
};
let thumbnails_bytes = thumbnails::get_cache_size().unwrap_or(0);
let thumbnail_count = if let Ok(dir) = thumbnails::get_thumbnails_dir() {
std::fs::read_dir(&dir).map(|e| e.count()).unwrap_or(0)
} else {
0
};
Ok(V2LibraryCacheStats {
artwork_cache_bytes: artwork_bytes,
thumbnails_cache_bytes: thumbnails_bytes,
artwork_file_count: artwork_count,
thumbnail_file_count: thumbnail_count,
})
}
#[tauri::command]
pub async fn v2_playlist_get_all_settings(
state: State<'_, LibraryState>,
) -> Result<Vec<PlaylistSettings>, String> {
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_all_playlist_settings().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_playlist_get_favorites(state: State<'_, LibraryState>) -> Result<Vec<u64>, String> {
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_favorite_playlist_ids().map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_playlist_get_local_tracks_with_position(
playlistId: u64,
state: State<'_, LibraryState>,
) -> Result<Vec<PlaylistLocalTrack>, String> {
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_playlist_local_tracks_with_position(playlistId)
.map_err(|e| e.to_string())
}
/// Add a Plex track (identified by its Plex ratingKey string) to a
/// Qobuz playlist. Plex tracks live in a parallel playlist_plex_tracks
/// table and never hit the Qobuz playlist API, so there's no risk of
/// the remote playlist getting polluted with unreachable ids.
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_playlist_add_plex_track(
playlistId: u64,
ratingKey: String,
position: i32,
state: State<'_, LibraryState>,
) -> Result<(), String> {
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.add_plex_track_to_playlist(playlistId, &ratingKey, position)
.map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_playlist_remove_plex_track(
playlistId: u64,
ratingKey: String,
state: State<'_, LibraryState>,
) -> Result<(), String> {
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.remove_plex_track_from_playlist(playlistId, &ratingKey)
.map_err(|e| e.to_string())
}
/// Return the (ratingKey, position) pairs for every Plex track in a
/// playlist. Caller hydrates metadata (title, artist, cover) from the
/// Plex cache.
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_playlist_get_plex_tracks_with_position(
playlistId: u64,
state: State<'_, LibraryState>,
) -> Result<Vec<(String, i32)>, String> {
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.get_playlist_plex_tracks_with_position(playlistId)
.map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_playlist_get_settings(
playlistId: u64,
state: State<'_, LibraryState>,
) -> Result<Option<PlaylistSettings>, String> {
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_playlist_settings(playlistId)
.map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_playlist_get_stats(
playlistId: u64,
state: State<'_, LibraryState>,
) -> Result<Option<PlaylistStats>, String> {
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_playlist_stats(playlistId).map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_playlist_increment_play_count(
playlistId: u64,
state: State<'_, LibraryState>,
) -> Result<PlaylistStats, String> {
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.increment_playlist_play_count(playlistId)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_playlist_get_all_stats(
state: State<'_, LibraryState>,
) -> Result<Vec<PlaylistStats>, String> {
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_all_playlist_stats().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_playlist_get_all_local_track_counts(
state: State<'_, LibraryState>,
) -> Result<std::collections::HashMap<u64, u32>, String> {
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_all_playlist_local_track_counts()
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_get_playlist_folders(
state: State<'_, LibraryState>,
) -> Result<Vec<crate::library::PlaylistFolder>, String> {
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_all_playlist_folders().map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_create_playlist_folder(
name: String,
iconType: Option<String>,
iconPreset: Option<String>,
iconColor: Option<String>,
state: State<'_, LibraryState>,
) -> Result<crate::library::PlaylistFolder, String> {
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.create_playlist_folder(
&name,
iconType.as_deref(),
iconPreset.as_deref(),
iconColor.as_deref(),
)
.map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_update_playlist_folder(
id: String,
name: Option<String>,
iconType: Option<String>,
iconPreset: Option<String>,
iconColor: Option<String>,
customImagePath: Option<String>,
isHidden: Option<bool>,
state: State<'_, LibraryState>,
) -> Result<crate::library::PlaylistFolder, String> {
log::info!("Command: update_playlist_folder {}", id);
// Handle custom image - copy to persistent storage if provided
// Uses Option<Option<&str>> semantics: None = don't update, Some(None) = clear, Some(Some(path)) = set new
let final_custom_image: Option<Option<String>> = if let Some(source_path) = customImagePath {
if source_path.is_empty() {
// Empty string means clear the image
Some(None)
} else {
let source = std::path::Path::new(&source_path);
if !source.exists() {
return Err(format!("Source image does not exist: {}", source_path));
}
let artwork_dir = get_artwork_cache_dir();
let extension = source.extension().and_then(|e| e.to_str()).unwrap_or("jpg");
let filename = format!(
"folder_{}_{}.{}",
id,
chrono::Utc::now().timestamp(),
extension
);
let dest_path = artwork_dir.join(filename);
std::fs::copy(source, &dest_path)
.map_err(|e| format!("Failed to copy image: {}", e))?;
log::info!("Copied folder image to: {}", dest_path.display());
Some(Some(dest_path.to_string_lossy().to_string()))
}
} else {
None
};
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.update_playlist_folder(
&id,
name.as_deref(),
iconType.as_deref(),
iconPreset.as_deref(),
iconColor.as_deref(),
final_custom_image.as_ref().map(|o| o.as_deref()),
isHidden,
)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_library_get_albums(
include_hidden: Option<bool>,
exclude_network_folders: Option<bool>,
state: State<'_, LibraryState>,
download_settings_state: State<'_, DownloadSettingsState>,
) -> Result<Vec<LocalAlbum>, String> {
let include_qobuz = download_settings_state
.lock()
.map_err(|e| format!("Failed to lock download settings: {}", e))?
.as_ref()
.and_then(|s| s.get_settings().ok())
.map(|s| s.show_in_library)
.unwrap_or(false);
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_albums_with_full_filter(
include_hidden.unwrap_or(false),
include_qobuz,
exclude_network_folders.unwrap_or(false),
)
.map_err(|e| e.to_string())
}
/// V2 metadata-grouped albums for Local Library.
#[tauri::command]
pub async fn v2_library_get_albums_metadata(
include_hidden: Option<bool>,
exclude_network_folders: Option<bool>,
state: State<'_, LibraryState>,
download_settings_state: State<'_, DownloadSettingsState>,
) -> Result<Vec<LocalAlbum>, String> {
let include_qobuz = download_settings_state
.lock()
.map_err(|e| format!("Failed to lock download settings: {}", e))?
.as_ref()
.and_then(|s| s.get_settings().ok())
.map(|s| s.show_in_library)
.unwrap_or(false);
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_albums_metadata_grouped(
include_hidden.unwrap_or(false),
include_qobuz,
exclude_network_folders.unwrap_or(false),
)
.map_err(|e| e.to_string())
}
/// V2 paginated metadata-grouped albums for Local Library. Backs the
/// recycling-grid pool + chunked store on the frontend: caller asks for
/// `[offset, offset+limit)` and receives that page plus the total count
/// of albums matching the same filter (drives scrollbar pre-allocation).
///
/// When `include_plex` is true and the `plex_cache.db` cache file
/// exists in the user's data dir, the result is the UNION of local and
/// plex sources — sort, filter, and pagination apply to both at once.
#[tauri::command]
pub async fn v2_library_get_albums_page(
offset: u64,
limit: u64,
search: Option<String>,
sort_by: Option<String>,
sort_dir: Option<String>,
exclude_network_folders: Option<bool>,
include_plex: Option<bool>,
state: State<'_, LibraryState>,
download_settings_state: State<'_, DownloadSettingsState>,
) -> Result<qbz_library::AlbumsMetadataPage, String> {
let include_qobuz = download_settings_state
.lock()
.map_err(|e| format!("Failed to lock download settings: {}", e))?
.as_ref()
.and_then(|s| s.get_settings().ok())
.map(|s| s.show_in_library)
.unwrap_or(false);
// Resolve the plex_cache.db path the same way `plex/mod.rs`
// does (`dirs::data_dir() + qbz/plex_cache.db`). The qbz-library
// crate doesn't know about Plex — passing `None` here keeps the
// local-only behavior; passing the path lets the SQL ATTACH + UNION
// light up.
let plex_path: Option<std::path::PathBuf> = if include_plex.unwrap_or(false) {
dirs::data_dir().map(|d| d.join("qbz").join("plex_cache.db"))
} else {
None
};
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_albums_metadata_page(
offset,
limit,
search.as_deref(),
sort_by.as_deref().unwrap_or("artist"),
sort_dir.as_deref().unwrap_or("asc"),
include_qobuz,
exclude_network_folders.unwrap_or(false),
plex_path.as_deref(),
)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_library_get_stats(
state: State<'_, LibraryState>,
download_settings_state: State<'_, DownloadSettingsState>,
) -> Result<crate::library::LibraryStats, String> {
log::info!("Command: library_get_stats");
let include_qobuz = download_settings_state
.lock()
.map_err(|e| format!("Failed to lock download settings: {}", e))?
.as_ref()
.and_then(|s| s.get_settings().ok())
.map(|s| s.show_in_library)
.unwrap_or(false);
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_stats(include_qobuz).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_library_get_folders(state: State<'_, LibraryState>) -> Result<Vec<String>, String> {
log::info!("Command: library_get_folders");
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_folders().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_library_get_folders_with_metadata(
state: State<'_, LibraryState>,
) -> Result<Vec<crate::library::LibraryFolder>, String> {
log::info!("Command: library_get_folders_with_metadata");
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
let mut folders = db.get_folders_with_metadata().map_err(|e| e.to_string())?;
// Refresh network detection for folders without user override
for folder in &mut folders {
if !folder.user_override_network {
let path = std::path::Path::new(&folder.path);
let network_info = crate::network::is_network_path(path);
// Update if detection differs from stored value
if network_info.is_network != folder.is_network {
log::info!(
"Updating network status for folder {}: {} -> {}",
folder.path,
folder.is_network,
network_info.is_network
);
// Extract network filesystem type
let fs_type = network_info.mount_info.as_ref().and_then(|mi| {
if let crate::network::MountKind::Network(nfs) = &mi.kind {
Some(format!("{:?}", nfs).to_lowercase())
} else {
None
}
});
// Update database
let _ = db.update_folder_settings(
folder.id,
folder.alias.as_deref(),
folder.enabled,
network_info.is_network,
fs_type.as_deref(),
false, // not user override
);
// Update the folder struct for return
folder.is_network = network_info.is_network;
folder.network_fs_type = fs_type;
}
}
}
Ok(folders)
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_library_list_folder_children(
parentPath: String,
excludeNetworkFolders: Option<bool>,
state: State<'_, LibraryState>,
) -> Result<Vec<FolderTreeEntry>, String> {
log::info!(
"Command: v2_library_list_folder_children {} (exclude_network={:?})",
parentPath,
excludeNetworkFolders
);
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.list_folder_children(&parentPath, excludeNetworkFolders.unwrap_or(false))
.map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_library_list_folder_tracks(
folderPath: String,
excludeNetworkFolders: Option<bool>,
state: State<'_, LibraryState>,
) -> Result<Vec<LocalTrack>, String> {
log::info!(
"Command: v2_library_list_folder_tracks {} (exclude_network={:?})",
folderPath,
excludeNetworkFolders
);
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.list_folder_tracks(&folderPath, excludeNetworkFolders.unwrap_or(false))
.map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_library_list_folder_tracks_recursive(
folderPath: String,
excludeNetworkFolders: Option<bool>,
state: State<'_, LibraryState>,
) -> Result<Vec<LocalTrack>, String> {
log::info!(
"Command: v2_library_list_folder_tracks_recursive {} (exclude_network={:?})",
folderPath,
excludeNetworkFolders
);
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.list_folder_tracks_recursive(&folderPath, excludeNetworkFolders.unwrap_or(false))
.map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_library_count_folder_tracks(
folderPath: String,
excludeNetworkFolders: Option<bool>,
state: State<'_, LibraryState>,
) -> Result<u32, String> {
log::info!(
"Command: v2_library_count_folder_tracks {} (exclude_network={:?})",
folderPath,
excludeNetworkFolders
);
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.count_folder_tracks_recursive(&folderPath, excludeNetworkFolders.unwrap_or(false))
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_library_add_folder(
path: String,
state: State<'_, LibraryState>,
) -> Result<crate::library::LibraryFolder, String> {
use crate::network::{is_network_path, MountKind, NetworkFs};
log::info!("Command: library_add_folder {}", path);
// Validate path exists and is a directory
let path_ref = std::path::Path::new(&path);
if !path_ref.exists() {
return Err(format!("Path does not exist: {}", path));
}
if !path_ref.is_dir() {
return Err(format!("Path is not a directory: {}", path));
}
// Detect if this is a network folder
let network_info = is_network_path(path_ref);
let (is_network, fs_type) = if network_info.is_network {
let fs_type = network_info
.mount_info
.as_ref()
.and_then(|m| match &m.kind {
MountKind::Network(nfs) => Some(match nfs {
NetworkFs::Cifs => "cifs".to_string(),
NetworkFs::Nfs => "nfs".to_string(),
NetworkFs::Sshfs => "sshfs".to_string(),
NetworkFs::Rclone => "rclone".to_string(),
NetworkFs::Webdav => "webdav".to_string(),
NetworkFs::Gluster => "glusterfs".to_string(),
NetworkFs::Ceph => "ceph".to_string(),
NetworkFs::Other(s) => s.clone(),
}),
_ => None,
});
(true, fs_type)
} else {
(false, None)
};
log::info!(
"Folder network info: is_network={}, fs_type={:?}",
is_network,
fs_type
);
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
let id = db
.add_folder_with_network_info(&path, is_network, fs_type.as_deref())
.map_err(|e| e.to_string())?;
// Return the full folder info
db.get_folder_by_id(id)
.map_err(|e| e.to_string())?
.ok_or_else(|| "Failed to retrieve folder after insert".to_string())
}
#[tauri::command]
pub async fn v2_library_cleanup_missing_files(
state: State<'_, LibraryState>,
) -> Result<crate::library::CleanupResult, String> {
log::info!("Command: library_cleanup_missing_files");
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
// Get all track paths
let tracks = db.get_all_track_paths().map_err(|e| e.to_string())?;
let total = tracks.len();
log::info!("Checking {} tracks for missing files...", total);
// Find tracks whose files don't exist
let mut missing_ids: Vec<i64> = Vec::new();
for (id, path) in &tracks {
if !std::path::Path::new(path).exists() {
log::debug!("Missing file: {}", path);
missing_ids.push(*id);
}
}
log::info!(
"Found {} missing files out of {} tracks",
missing_ids.len(),
total
);
// Delete missing tracks in batches
let removed = if !missing_ids.is_empty() {
let mut total_removed = 0;
for chunk in missing_ids.chunks(500) {
let count = db.delete_tracks_by_ids(chunk).map_err(|e| e.to_string())?;
total_removed += count;
}
total_removed
} else {
0
};
log::info!("Removed {} tracks with missing files", removed);
Ok(crate::library::CleanupResult {
checked: total,
removed,
})
}
#[tauri::command]
pub async fn v2_library_fetch_missing_artwork(
state: State<'_, LibraryState>,
) -> Result<u32, String> {
log::info!("Command: library_fetch_missing_artwork");
// Get Discogs client (proxy handles credentials)
let discogs = crate::discogs::DiscogsClient::new();
let artwork_cache = get_artwork_cache_dir();
let mut updated_count = 0u32;
// Get all albums without artwork
let albums_without_artwork: Vec<(String, String, String)> = {
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_albums_without_artwork().map_err(|e| e.to_string())?
};
log::info!(
"Found {} albums without artwork",
albums_without_artwork.len()
);
for (group_key, album, artist) in albums_without_artwork {
// Try to fetch from Discogs
if let Some(artwork_path) = discogs.fetch_artwork(&artist, &album, &artwork_cache).await {
// Update all tracks in this album with the artwork
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
// Backfill: only runs for albums with NO existing artwork, so there's
// nothing per-track to destroy. Group-level write is correct here.
#[allow(deprecated)]
if db
.update_album_group_artwork(&group_key, &artwork_path)
.is_ok()
{
updated_count += 1;
log::info!("Updated artwork for {} - {}", artist, album);
}
}
// Small delay to respect rate limits (60 requests/min)
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
log::info!("Fetched artwork for {} albums from Discogs", updated_count);
Ok(updated_count)
}
#[tauri::command]
pub async fn v2_library_get_artists(
exclude_network_folders: Option<bool>,
state: State<'_, LibraryState>,
download_settings_state: State<'_, DownloadSettingsState>,
) -> Result<Vec<crate::library::LocalArtist>, String> {
log::info!(
"Command: library_get_artists (exclude_network: {:?})",
exclude_network_folders
);
// Get download settings
let include_qobuz = download_settings_state
.lock()
.map_err(|e| format!("Failed to lock download settings: {}", e))?
.as_ref()
.and_then(|s| s.get_settings().ok())
.map(|s| s.show_in_library)
.unwrap_or(false);
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
// Use optimized SQL-based filtering instead of N+1 query pattern
let artists = db
.get_artists_with_filter(include_qobuz, exclude_network_folders.unwrap_or(false))
.map_err(|e| e.to_string())?;
log::info!("Returning {} artists", artists.len());
Ok(artists)
}
#[tauri::command]
pub async fn v2_library_search(
query: String,
limit: Option<u32>,
exclude_network_folders: Option<bool>,
state: State<'_, LibraryState>,
download_settings_state: State<'_, DownloadSettingsState>,
) -> Result<Vec<crate::library::LocalTrack>, String> {
log::info!(
"Command: library_search \"{}\" (exclude_network: {:?})",
query,
exclude_network_folders
);
// Get download settings
let include_qobuz = download_settings_state
.lock()
.map_err(|e| format!("Failed to lock download settings: {}", e))?
.as_ref()
.and_then(|s| s.get_settings().ok())
.map(|s| s.show_in_library)
.unwrap_or(false);
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
// Use optimized SQL-based filtering
// limit = 0 means no limit (fetch all tracks)
let tracks = db
.search_with_filter(
&query,
limit.unwrap_or(0),
include_qobuz,
exclude_network_folders.unwrap_or(false),
)
.map_err(|e| e.to_string())?;
log::info!("Search returned {} tracks", tracks.len());
Ok(tracks)
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_library_get_album_tracks(
albumGroupKey: String,
state: State<'_, LibraryState>,
) -> Result<Vec<crate::library::LocalTrack>, String> {
log::info!("Command: library_get_album_tracks {}", albumGroupKey);
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_album_tracks(&albumGroupKey)
.map_err(|e| e.to_string())
}
/// V2 fetch of tracks for a metadata-grouped album.
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_library_get_album_tracks_metadata(
metadataKey: String,
state: State<'_, LibraryState>,
) -> Result<Vec<crate::library::LocalTrack>, String> {
log::info!("Command: v2_library_get_album_tracks_metadata {}", metadataKey);
let guard__ = state.db.lock().await;
let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_album_tracks_metadata(&metadataKey)
.map_err(|e| e.to_string())
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_plex_get_music_sections(
baseUrl: String,
token: String,
) -> Result<Vec<PlexMusicSection>, String> {
crate::plex::plex_get_music_sections(baseUrl, token).await
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_plex_get_section_tracks(
baseUrl: String,
token: String,
sectionKey: String,
limit: Option<u32>,
) -> Result<Vec<PlexTrack>, String> {
crate::plex::plex_get_section_tracks(baseUrl, token, sectionKey, limit).await
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_plex_play_track(
baseUrl: String,
token: String,
ratingKey: String,
bridge: State<'_, CoreBridgeState>,
runtime: State<'_, RuntimeManagerState>,
) -> Result<PlexPlayResult, String> {
runtime
.manager()
.check_requirements(CommandRequirement::RequiresClientInit)
.await
.map_err(|e| e.to_string())?;
let resolved = crate::plex::plex_resolve_track_media(baseUrl, token, ratingKey).await?;
let bridge_guard = bridge.get().await;
let player = bridge_guard.player();
player
.play_data(resolved.bytes.clone(), resolved.playback_id)
.map_err(|e| format!("Failed to play Plex track via V2 player: {}", e))?;
Ok(PlexPlayResult {
rating_key: resolved.rating_key,
part_key: resolved.part_key,
part_url: resolved.part_url,
bytes: resolved.bytes.len(),
direct_play_confirmed: resolved.direct_play_confirmed,
content_type: resolved.content_type,
sampling_rate_hz: resolved.sampling_rate_hz,
bit_depth: resolved.bit_depth,
})
}
#[tauri::command]
pub async fn v2_library_update_folder_path(
id: i64,
new_path: String,
state: State<'_, LibraryState>,
) -> Result<crate::library::LibraryFolder, String> {
let path_ref = std::path::Path::new(&new_path);
if !path_ref.exists() {
return Err("The selected folder does not exist".to_string());
}
if !path_ref.is_dir() {
return Err("The selected path is not a folder".to_string());
}
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.update_folder_path(id, &new_path)
.map_err(|e| e.to_string())?;
let network_info = crate::network::is_network_path(path_ref);
if network_info.is_network {
let fs_type = network_info.mount_info.as_ref().and_then(|mi| {
if let crate::network::MountKind::Network(nfs) = &mi.kind {
Some(format!("{:?}", nfs).to_lowercase())
} else {
None
}
});
if let Some(folder) = db.get_folder_by_id(id).map_err(|e| e.to_string())? {
db.update_folder_settings(
id,
folder.alias.as_deref(),
folder.enabled,
true,
fs_type.as_deref(),
false,
)
.map_err(|e| e.to_string())?;
}
}
db.get_folder_by_id(id)
.map_err(|e| e.to_string())?
.ok_or_else(|| "Folder not found after update".to_string())
}
#[tauri::command]
pub async fn v2_library_cache_artist_image(
artist_name: String,
image_url: String,
source: String,
canonical_name: Option<String>,
state: State<'_, LibraryState>,
) -> Result<(), String> {
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.cache_artist_image_with_canonical(
&artist_name,
Some(&image_url),
&source,
None,
canonical_name.as_deref(),
)
.map_err(|e| e.to_string())
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CustomArtistImageResult {
pub image_path: String,
pub thumbnail_path: String,
}
#[tauri::command]
pub async fn v2_library_set_custom_artist_image(
artist_name: String,
custom_image_path: String,
state: State<'_, LibraryState>,
) -> Result<CustomArtistImageResult, String> {
let artwork_dir = get_artwork_cache_dir();
let source = std::path::Path::new(&custom_image_path);
if !source.exists() {
return Err(format!(
"Source image does not exist: {}",
custom_image_path
));
}
// Validate extension
let extension = source
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.unwrap_or_default();
if !["png", "jpg", "jpeg", "webp"].contains(&extension.as_str()) {
return Err(format!(
"Unsupported image format: {}. Use png, jpg, jpeg, or webp.",
extension
));
}
// Generate filename using MD5 hash of artist name
let mut hasher = Md5::new();
hasher.update(artist_name.as_bytes());
let artist_hash = format!("{:x}", hasher.finalize());
let timestamp = chrono::Utc::now().timestamp();
let filename = format!("artist_custom_{}_{}.jpg", artist_hash, timestamp);
let dest_path = artwork_dir.join(&filename);
// Decode, resize to max 1000x1000, save as JPEG
let img = image::ImageReader::open(source)
.map_err(|e| format!("Failed to open image: {}", e))?
.decode()
.map_err(|e| format!("Failed to decode image: {}", e))?;
let resized = img.resize(1000, 1000, image::imageops::FilterType::Lanczos3);
resized
.save(&dest_path)
.map_err(|e| format!("Failed to save resized image: {}", e))?;
// Generate 500x500 thumbnail using qbz-library
let thumbnail_path = thumbnails::generate_thumbnail(&dest_path)
.map_err(|e| format!("Failed to generate thumbnail: {}", e))?;
// Update database
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.cache_artist_image(
&artist_name,
None,
"custom",
Some(&dest_path.to_string_lossy()),
)
.map_err(|e| e.to_string())?;
Ok(CustomArtistImageResult {
image_path: dest_path.to_string_lossy().into_owned(),
thumbnail_path: thumbnail_path.to_string_lossy().into_owned(),
})
}
#[tauri::command]
pub async fn v2_library_remove_custom_artist_image(
artist_name: String,
state: State<'_, LibraryState>,
) -> Result<(), String> {
// Get current info to find file paths to delete
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
let info = db
.get_artist_image(&artist_name)
.map_err(|e| e.to_string())?;
if let Some(info) = info {
// Delete custom image file if it exists
if let Some(ref path) = info.custom_image_path {
let p = std::path::Path::new(path);
if p.exists() {
// Also remove thumbnail
if let Ok(thumb) = thumbnails::get_thumbnail_path(p) {
let _ = std::fs::remove_file(thumb);
}
let _ = std::fs::remove_file(p);
}
}
// Reset to original image (clear custom_image_path, keep image_url)
db.cache_artist_image(
&artist_name,
info.image_url.as_deref(),
info.source.as_deref().unwrap_or("qobuz"),
None,
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
pub async fn v2_library_get_artist_image(
artist_name: String,
state: State<'_, LibraryState>,
) -> Result<Option<crate::library::ArtistImageInfo>, String> {
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.get_artist_image(&artist_name).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn v2_library_get_all_custom_artist_images(
state: State<'_, LibraryState>,
) -> Result<std::collections::HashMap<String, String>, String> {
let guard = state.db.lock().await;
let db = guard.as_ref().ok_or("No active session - please log in")?;
db.get_all_custom_artist_images().map_err(|e| e.to_string())
}