Merge branch 'pre-release' into updates/uiux-1.1.7

This commit is contained in:
Victor RH committed 2026-01-26 22:01:42 -06:00
commit 53060ebd4d
52 files changed
+5570 -620

No files matched your search

+23 -1
View File
@@ -9,7 +9,7 @@ use std::time::Duration;
use crate::cast::device::CastDeviceConnection;
use crate::cast::errors::CastError;
use crate::cast::{CastStatus, MediaMetadata};
use crate::cast::{CastStatus, MediaMetadata, CastPositionInfo};
/// Commands sent to the Chromecast thread
pub enum CastCommand {
@@ -24,6 +24,9 @@ pub enum CastCommand {
GetStatus {
reply: Sender<Result<CastStatus, CastError>>,
},
GetMediaPosition {
reply: Sender<Result<CastPositionInfo, CastError>>,
},
LoadMedia {
url: String,
content_type: String,
@@ -188,6 +191,17 @@ impl ChromecastHandle {
.recv()
.map_err(|_| CastError::Connection("Thread response error".to_string()))?
}
/// Get media position for seekbar updates
pub fn get_media_position(&self) -> Result<CastPositionInfo, CastError> {
let (reply_tx, reply_rx) = mpsc::channel();
self.sender
.send(CastCommand::GetMediaPosition { reply: reply_tx })
.map_err(|_| CastError::Connection("Thread communication error".to_string()))?;
reply_rx
.recv()
.map_err(|_| CastError::Connection("Thread response error".to_string()))?
}
}
impl Drop for ChromecastHandle {
@@ -248,6 +262,14 @@ fn chromecast_thread_main(receiver: Receiver<CastCommand>) {
let _ = reply.send(result);
}
CastCommand::GetMediaPosition { reply } => {
let result = match connection.as_mut() {
Some(conn) => conn.get_media_position(),
None => Err(CastError::NotConnected),
};
let _ = reply.send(result);
}
CastCommand::LoadMedia {
url,
content_type,
+6 -1
View File
@@ -7,7 +7,7 @@ use tokio::sync::Mutex;
use crate::api::models::Quality;
use crate::AppState;
use crate::cast::{
CastError, CastStatus, DeviceDiscovery, DiscoveredDevice, MediaMetadata, MediaServer,
CastError, CastStatus, CastPositionInfo, DeviceDiscovery, DiscoveredDevice, MediaMetadata, MediaServer,
};
use crate::cast::chromecast_thread::ChromecastHandle;
use crate::library::{AudioFormat, LibraryState};
@@ -110,6 +110,11 @@ pub async fn cast_get_status(state: State<'_, CastState>) -> Result<CastStatus,
state.chromecast.get_status().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn cast_get_position(state: State<'_, CastState>) -> Result<CastPositionInfo, String> {
state.chromecast.get_media_position().map_err(|e| e.to_string())
}
// === Playback ===
#[tauri::command]
+60 -1
View File
@@ -3,7 +3,7 @@
use serde::{Deserialize, Serialize};
use rust_cast::CastDevice;
use rust_cast::channels::media::{Image, Media, Metadata, MusicTrackMediaMetadata, StreamType};
use rust_cast::channels::media::{Image, Media, Metadata, MusicTrackMediaMetadata, StreamType, PlayerState, IdleReason};
use rust_cast::channels::receiver::{CastDeviceApp, Status as ReceiverStatus};
use crate::cast::CastError;
@@ -20,6 +20,15 @@ pub struct MediaMetadata {
pub duration_secs: Option<u64>,
}
/// Media position info for seekbar updates
#[derive(Debug, Clone, Serialize)]
pub struct CastPositionInfo {
pub position_secs: f64,
pub duration_secs: f64,
pub player_state: String,
pub idle_reason: Option<String>,
}
/// Device status for frontend
#[derive(Debug, Clone, Serialize)]
pub struct CastStatus {
@@ -208,6 +217,56 @@ impl CastDeviceConnection {
Ok(())
}
/// Get current media position for seekbar updates
pub fn get_media_position(&mut self) -> Result<CastPositionInfo, CastError> {
let session = self.session.as_ref().ok_or(CastError::NotConnected)?;
let destination = &session.transport_id;
let status = self
.device
.media
.get_status(destination.as_str(), None)
.map_err(|e| CastError::Media(e.to_string()))?;
// Get the first media entry (current playing item)
if let Some(entry) = status.entries.first() {
let player_state = match entry.player_state {
PlayerState::Idle => "IDLE",
PlayerState::Playing => "PLAYING",
PlayerState::Paused => "PAUSED",
PlayerState::Buffering => "BUFFERING",
};
let idle_reason = entry.idle_reason.as_ref().map(|r| match r {
IdleReason::Cancelled => "CANCELLED".to_string(),
IdleReason::Interrupted => "INTERRUPTED".to_string(),
IdleReason::Finished => "FINISHED".to_string(),
IdleReason::Error => "ERROR".to_string(),
});
let duration = entry.media.as_ref()
.and_then(|m| m.duration)
.unwrap_or(0.0) as f64;
let position = entry.current_time.unwrap_or(0.0) as f64;
Ok(CastPositionInfo {
position_secs: position,
duration_secs: duration,
player_state: player_state.to_string(),
idle_reason,
})
} else {
// No media playing
Ok(CastPositionInfo {
position_secs: 0.0,
duration_secs: 0.0,
player_state: "IDLE".to_string(),
idle_reason: None,
})
}
}
fn ensure_session(&mut self) -> Result<(), CastError> {
if self.session.is_some() {
return Ok(());
+15 -2
View File
@@ -7,7 +7,7 @@ use tokio::sync::Mutex;
use crate::api::models::Quality;
use crate::AppState;
use crate::cast::dlna::{
DiscoveredDlnaDevice, DlnaConnection, DlnaDiscovery, DlnaError, DlnaMetadata, DlnaStatus,
DiscoveredDlnaDevice, DlnaConnection, DlnaDiscovery, DlnaError, DlnaMetadata, DlnaPositionInfo, DlnaStatus,
};
use crate::cast::MediaServer;
@@ -101,6 +101,14 @@ pub async fn dlna_get_status(state: State<'_, DlnaState>) -> Result<DlnaStatus,
Ok(conn.get_status())
}
/// Get current playback position from DLNA device
#[tauri::command]
pub async fn dlna_get_position(state: State<'_, DlnaState>) -> Result<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())
}
// === Playback ===
/// Play a Qobuz track on the DLNA device
@@ -111,6 +119,8 @@ pub async fn dlna_play_track(
dlna_state: State<'_, DlnaState>,
app_state: State<'_, AppState>,
) -> Result<(), String> {
log::info!("DLNA: dlna_play_track called for track_id={}", track_id);
// Get stream URL from Qobuz
let stream_url = {
let client = app_state.client.lock().await;
@@ -152,11 +162,14 @@ pub async fn dlna_play_track(
url.ok_or_else(|| "Failed to build media URL".to_string())?
};
log::info!("DLNA: Playing track {} via MediaServer URL: {}", track_id, url);
log::info!("DLNA: Content-Type from Qobuz: {}", content_type);
// Load media on DLNA device
{
let mut connection = dlna_state.connection.lock().await;
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?;
conn.load_media(&url, &metadata).await.map_err(|e| e.to_string())?;
conn.load_media(&url, &metadata, &content_type).await.map_err(|e| e.to_string())?;
}
// Start playback
+118 -14
View File
@@ -17,6 +17,14 @@ pub struct DlnaMetadata {
pub duration_secs: Option<u64>,
}
/// DLNA playback position info
#[derive(Debug, Clone, Serialize)]
pub struct DlnaPositionInfo {
pub position_secs: u64,
pub duration_secs: u64,
pub transport_state: String, // PLAYING, PAUSED_PLAYBACK, STOPPED, etc.
}
/// DLNA device status
#[derive(Debug, Clone, Serialize)]
pub struct DlnaStatus {
@@ -101,7 +109,7 @@ impl DlnaConnection {
}
/// Set the media URI and start playback
pub async fn load_media(&mut self, uri: &str, metadata: &DlnaMetadata) -> Result<(), DlnaError> {
pub async fn load_media(&mut self, uri: &str, metadata: &DlnaMetadata, content_type: &str) -> Result<(), DlnaError> {
if !self.connected {
return Err(DlnaError::NotConnected);
}
@@ -109,8 +117,12 @@ impl DlnaConnection {
let av_service = self.av_transport_service.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
// Build DIDL-Lite metadata
let didl_metadata = build_didl_metadata(uri, metadata);
// Build DIDL-Lite metadata with actual content type
let didl_metadata = build_didl_metadata(uri, metadata, content_type);
log::info!("DLNA: Loading media URI: {}", uri);
log::info!("DLNA: Content-Type: {}", content_type);
log::info!("DLNA: DIDL Metadata:\n{}", didl_metadata);
let payload = format!(
"<InstanceID>0</InstanceID><CurrentURI>{}</CurrentURI><CurrentURIMetaData>{}</CurrentURIMetaData>",
@@ -118,11 +130,21 @@ impl DlnaConnection {
xml_escape(&didl_metadata)
);
av_service
.action(&self.device_url, "SetAVTransportURI", &payload)
.await
.map_err(|e| DlnaError::Playback(e.to_string()))?;
let response = tokio::time::timeout(
std::time::Duration::from_secs(10),
av_service.action(&self.device_url, "SetAVTransportURI", &payload)
)
.await
.map_err(|_| {
log::error!("DLNA: SetAVTransportURI timed out after 10s");
DlnaError::Playback("SetAVTransportURI timed out".to_string())
})?
.map_err(|e| {
log::error!("DLNA: SetAVTransportURI failed: {}", e);
DlnaError::Playback(e.to_string())
})?;
log::info!("DLNA: SetAVTransportURI response: {:?}", response);
self.current_uri = Some(uri.to_string());
log::info!("DLNA: Set URI to {}", uri);
@@ -138,13 +160,23 @@ impl DlnaConnection {
let av_service = self.av_transport_service.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
av_service
.action(&self.device_url, "Play", "<InstanceID>0</InstanceID><Speed>1</Speed>")
.await
.map_err(|e| DlnaError::Playback(e.to_string()))?;
let response = tokio::time::timeout(
std::time::Duration::from_secs(10),
av_service.action(&self.device_url, "Play", "<InstanceID>0</InstanceID><Speed>1</Speed>")
)
.await
.map_err(|_| {
log::error!("DLNA: Play action timed out after 10s");
DlnaError::Playback("Play action timed out".to_string())
})?
.map_err(|e| {
log::error!("DLNA: Play action failed: {}", e);
DlnaError::Playback(e.to_string())
})?;
log::info!("DLNA: Play response: {:?}", response);
self.is_playing = true;
log::info!("DLNA: Play");
log::info!("DLNA: Play started successfully");
Ok(())
}
@@ -241,10 +273,77 @@ impl DlnaConnection {
Ok(())
}
/// Get current playback position and transport state
pub async fn get_position_info(&self) -> Result<DlnaPositionInfo, DlnaError> {
if !self.connected {
return Err(DlnaError::NotConnected);
}
let av_service = self.av_transport_service.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
// Get position info
let position_response = tokio::time::timeout(
std::time::Duration::from_secs(5),
av_service.action(&self.device_url, "GetPositionInfo", "<InstanceID>0</InstanceID>")
)
.await
.map_err(|_| DlnaError::Playback("GetPositionInfo timed out".to_string()))?
.map_err(|e| DlnaError::Playback(e.to_string()))?;
// Get transport state
let transport_response = tokio::time::timeout(
std::time::Duration::from_secs(5),
av_service.action(&self.device_url, "GetTransportInfo", "<InstanceID>0</InstanceID>")
)
.await
.map_err(|_| DlnaError::Playback("GetTransportInfo timed out".to_string()))?
.map_err(|e| DlnaError::Playback(e.to_string()))?;
// Parse RelTime (position) - format: "HH:MM:SS" or "H:MM:SS"
let rel_time = position_response.get("RelTime")
.map(|s| s.as_str())
.unwrap_or("0:00:00");
let position_secs = parse_time_string(rel_time);
// Parse TrackDuration - format: "HH:MM:SS"
let track_duration = position_response.get("TrackDuration")
.map(|s| s.as_str())
.unwrap_or("0:00:00");
let duration_secs = parse_time_string(track_duration);
// Get transport state (PLAYING, PAUSED_PLAYBACK, STOPPED, etc.)
let transport_state = transport_response.get("CurrentTransportState")
.map(|s| s.to_string())
.unwrap_or_else(|| "UNKNOWN".to_string());
Ok(DlnaPositionInfo {
position_secs,
duration_secs,
transport_state,
})
}
}
/// Parse time string "HH:MM:SS" or "H:MM:SS" to seconds
fn parse_time_string(time: &str) -> u64 {
let parts: Vec<&str> = time.split(':').collect();
if parts.len() != 3 {
return 0;
}
let hours: u64 = parts[0].parse().unwrap_or(0);
let minutes: u64 = parts[1].parse().unwrap_or(0);
let seconds: u64 = parts[2].split('.').next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
hours * 3600 + minutes * 60 + seconds
}
/// Build DIDL-Lite metadata for a track
fn build_didl_metadata(uri: &str, metadata: &DlnaMetadata) -> String {
fn build_didl_metadata(uri: &str, metadata: &DlnaMetadata, content_type: &str) -> String {
let duration = metadata.duration_secs.map(|d| {
let hours = d / 3600;
let minutes = (d % 3600) / 60;
@@ -256,6 +355,10 @@ fn build_didl_metadata(uri: &str, metadata: &DlnaMetadata) -> String {
.map(|url| format!(r#"<upnp:albumArtURI>{}</upnp:albumArtURI>"#, xml_escape(url)))
.unwrap_or_default();
// Use actual content type for protocolInfo - critical for DLNA compatibility
// Many devices reject content if protocolInfo doesn't match actual MIME type
let protocol_info = format!("http-get:*:{}:*", content_type);
format!(
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
<item id="0" parentID="-1" restricted="1">
@@ -264,7 +367,7 @@ fn build_didl_metadata(uri: &str, metadata: &DlnaMetadata) -> String {
<upnp:album>{}</upnp:album>
<upnp:artist>{}</upnp:artist>
{}
<res duration="{}" protocolInfo="http-get:*:audio/flac:*">{}</res>
<res duration="{}" protocolInfo="{}">{}</res>
<upnp:class>object.item.audioItem.musicTrack</upnp:class>
</item>
</DIDL-Lite>"#,
@@ -274,6 +377,7 @@ fn build_didl_metadata(uri: &str, metadata: &DlnaMetadata) -> String {
xml_escape(&metadata.artist),
artwork,
duration,
protocol_info,
xml_escape(uri)
)
}
+1 -1
View File
@@ -6,6 +6,6 @@ pub mod discovery;
pub mod errors;
pub use commands::DlnaState;
pub use device::{DlnaConnection, DlnaMetadata, DlnaStatus};
pub use device::{DlnaConnection, DlnaMetadata, DlnaPositionInfo, DlnaStatus};
pub use discovery::{DiscoveredDlnaDevice, DlnaDiscovery};
pub use errors::DlnaError;
+32 -5
View File
@@ -38,8 +38,14 @@ pub struct MediaServer {
impl MediaServer {
/// Start server on available port
/// Uses port 9876 by default for DLNA compatibility, falls back to random port if busy
pub fn start() -> Result<Self, CastError> {
let server = Server::http("0.0.0.0:0")
// Try fixed port first for easier firewall configuration
let server = Server::http("0.0.0.0:9876")
.or_else(|_| {
log::warn!("MediaServer: Port 9876 busy, using random port");
Server::http("0.0.0.0:0")
})
.map_err(|e| CastError::Server(format!("Failed to start HTTP server: {}", e)))?;
let port = server
@@ -50,14 +56,18 @@ impl MediaServer {
let base_ip = local_ip().unwrap_or_else(|| IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
let base_url = format_base_url(base_ip, port);
log::info!("MediaServer: Started on {}:{} (base_url: {})", base_ip, port, base_url);
let entries = Arc::new(Mutex::new(HashMap::new()));
let shutdown = Arc::new(AtomicBool::new(false));
let entries_clone = entries.clone();
let shutdown_clone = shutdown.clone();
let port_for_log = port;
let handle = thread::spawn(move || {
log::info!("MediaServer: Thread started, listening on port {}", port_for_log);
while !shutdown_clone.load(Ordering::SeqCst) {
match server.recv_timeout(Duration::from_millis(250)) {
Ok(Some(request)) => {
@@ -65,9 +75,13 @@ impl MediaServer {
let _ = request.respond(response);
}
Ok(None) => {}
Err(_) => break,
Err(e) => {
log::error!("MediaServer: Thread error: {:?}, exiting", e);
break;
}
}
}
log::info!("MediaServer: Thread exiting");
});
Ok(Self {
@@ -177,18 +191,31 @@ fn handle_request(
request: &tiny_http::Request,
entries: &Arc<Mutex<HashMap<u64, MediaEntry>>>,
) -> Response<std::io::Cursor<Vec<u8>>> {
// Log all incoming requests for debugging
log::info!("MediaServer: {} request from {:?} for {}", method, request.remote_addr(), url);
if method != &Method::Get {
log::warn!("MediaServer: Rejected non-GET request: {}", method);
return Response::from_data(Vec::new()).with_status_code(StatusCode(405));
}
let id = match parse_audio_id(url) {
Some(id) => id,
None => return Response::from_data(Vec::new()).with_status_code(StatusCode(404)),
None => {
log::warn!("MediaServer: 404 - Could not parse audio ID from URL: {}", url);
return Response::from_data(Vec::new()).with_status_code(StatusCode(404));
}
};
let entry = match entries.lock().ok().and_then(|map| map.get(&id).cloned()) {
Some(entry) => entry,
None => return Response::from_data(Vec::new()).with_status_code(StatusCode(404)),
Some(entry) => {
log::info!("MediaServer: Found entry for ID {}, content-type: {}, size: {} bytes", id, entry.content_type, entry.size);
entry
}
None => {
log::warn!("MediaServer: 404 - No entry found for ID: {}", id);
return Response::from_data(Vec::new()).with_status_code(StatusCode(404));
}
};
let range_header = request
+1 -1
View File
@@ -10,7 +10,7 @@ pub mod airplay;
pub mod dlna;
pub use commands::CastState;
pub use device::{CastDeviceConnection, CastStatus, MediaMetadata};
pub use device::{CastDeviceConnection, CastStatus, MediaMetadata, CastPositionInfo};
pub use discovery::{DeviceDiscovery, DiscoveredDevice};
pub use errors::CastError;
pub use media_server::MediaServer;
+2
View File
@@ -22,6 +22,7 @@ pub mod radio;
pub mod search;
pub mod share;
pub mod smart_playlists;
pub mod remote_metadata;
pub use audio_backends::*;
pub use audio_diagnostics::*;
@@ -43,3 +44,4 @@ pub use radio::*;
pub use search::*;
pub use share::*;
pub use smart_playlists::*;
pub use remote_metadata::*;
+104
View File
@@ -0,0 +1,104 @@
//! Tauri commands for remote metadata fetching (MusicBrainz, Discogs)
//!
//! These commands allow the Tag Editor to search for and fetch
//! album metadata from external services.
use std::sync::Arc;
use tauri::State;
use tokio::sync::Mutex;
use crate::library::remote_metadata::{
RemoteAlbumMetadata, RemoteAlbumSearchResult, RemoteMetadataState, RemoteProvider,
};
/// State wrapper for Tauri
pub struct RemoteMetadataSharedState {
pub inner: Arc<Mutex<RemoteMetadataState>>,
}
/// Search for albums on a remote provider
///
/// # Arguments
/// * `provider` - "musicbrainz" or "discogs"
/// * `query` - Album title to search for
/// * `artist` - Optional artist name to narrow search
/// * `limit` - Maximum results (1-25)
#[tauri::command]
pub async fn remote_metadata_search(
provider: String,
query: String,
artist: Option<String>,
limit: Option<usize>,
state: State<'_, RemoteMetadataSharedState>,
) -> Result<Vec<RemoteAlbumSearchResult>, String> {
log::info!(
"Command: remote_metadata_search provider={} query={} artist={:?}",
provider,
query,
artist
);
let provider = parse_provider(&provider)?;
let limit = limit.unwrap_or(10).min(25);
let state_guard = state.inner.lock().await;
state_guard
.search_albums(provider, &query, artist.as_deref(), limit)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get full album metadata from a remote provider
///
/// # Arguments
/// * `provider` - "musicbrainz" or "discogs"
/// * `provider_id` - The provider-specific ID (MBID or Discogs release ID)
#[tauri::command]
pub async fn remote_metadata_get_album(
provider: String,
provider_id: String,
state: State<'_, RemoteMetadataSharedState>,
) -> Result<RemoteAlbumMetadata, String> {
log::info!(
"Command: remote_metadata_get_album provider={} id={}",
provider,
provider_id
);
let provider = parse_provider(&provider)?;
let state_guard = state.inner.lock().await;
state_guard
.get_album_metadata(provider, &provider_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get cache statistics
#[tauri::command]
pub async fn remote_metadata_cache_stats(
state: State<'_, RemoteMetadataSharedState>,
) -> Result<crate::library::remote_metadata::CacheStats, String> {
let state_guard = state.inner.lock().await;
Ok(state_guard.cache.stats().await)
}
/// Clear the remote metadata cache
#[tauri::command]
pub async fn remote_metadata_clear_cache(
state: State<'_, RemoteMetadataSharedState>,
) -> Result<(), String> {
log::info!("Command: remote_metadata_clear_cache");
let state_guard = state.inner.lock().await;
state_guard.cache.clear_all().await;
Ok(())
}
/// Parse provider string to enum
fn parse_provider(provider: &str) -> Result<RemoteProvider, String> {
match provider.to_lowercase().as_str() {
"musicbrainz" | "mb" => Ok(RemoteProvider::MusicBrainz),
"discogs" => Ok(RemoteProvider::Discogs),
_ => Err(format!("Unknown provider: {}", provider)),
}
}
+140 -1
View File
@@ -44,7 +44,7 @@ pub struct DiscogsImageOption {
pub release_year: Option<u32>,
}
/// Release details from Discogs API
/// Release details from Discogs API (internal, for artwork)
#[derive(Debug, Deserialize)]
struct ReleaseDetails {
id: u64,
@@ -63,6 +63,69 @@ struct ReleaseImage {
image_type: String,
}
// ============ Public Metadata Structures ============
/// Full release metadata from Discogs (for tag editor)
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
pub struct DiscogsReleaseMetadata {
pub id: u64,
pub title: String,
pub artists: Option<Vec<DiscogsArtist>>,
pub year: Option<u32>,
pub genres: Option<Vec<String>>,
pub styles: Option<Vec<String>>,
pub labels: Option<Vec<DiscogsLabel>>,
pub tracklist: Option<Vec<DiscogsTrack>>,
pub country: Option<String>,
/// URL to view on Discogs
pub uri: Option<String>,
}
/// Artist in Discogs release
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
pub struct DiscogsArtist {
pub name: String,
pub id: Option<u64>,
/// Join phrase (e.g., " & ", " feat. ")
pub join: Option<String>,
}
/// Label in Discogs release
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
pub struct DiscogsLabel {
pub name: String,
pub catno: Option<String>,
pub id: Option<u64>,
}
/// Track in Discogs release
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
pub struct DiscogsTrack {
/// Position (e.g., "1", "A1", "1-1")
pub position: String,
pub title: String,
/// Duration as string (e.g., "3:45")
pub duration: Option<String>,
/// Track type (e.g., "track", "heading")
#[serde(rename = "type_")]
pub track_type: Option<String>,
}
/// Extended search result with more metadata
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
pub struct DiscogsSearchResultExtended {
pub id: u64,
pub title: String,
#[serde(rename = "type")]
pub result_type: String,
pub year: Option<String>,
pub country: Option<String>,
pub label: Option<Vec<String>>,
pub catno: Option<String>,
pub format: Option<Vec<String>>,
pub cover_image: Option<String>,
}
impl DiscogsClient {
/// Create a new Discogs client (proxy handles credentials)
pub fn new() -> Self {
@@ -364,6 +427,82 @@ impl DiscogsClient {
Ok(search)
}
/// Search for releases with extended metadata
/// Returns up to `limit` results with detailed release information
pub async fn search_releases(
&self,
artist: &str,
album: &str,
catalog_number: Option<&str>,
limit: usize,
) -> Result<Vec<DiscogsSearchResultExtended>, String> {
// Build search query - prefer catalog number if available
let query = if let Some(catno) = catalog_number.filter(|s| !s.trim().is_empty()) {
catno.to_string()
} else {
format!("{} {}", artist, album)
};
let url = format!(
"{}/search?q={}&type=release&per_page={}",
DISCOGS_PROXY_URL,
urlencoding::encode(&query),
limit.min(25)
);
log::debug!(
"Searching Discogs releases for: {} - {} (catalog: {:?})",
artist,
album,
catalog_number
);
let response = self.client.get(&url).send().await
.map_err(|e| format!("Failed to search Discogs: {}", e))?;
if !response.status().is_success() {
return Err(format!("Discogs search failed with status: {}", response.status()));
}
// Parse response with extended fields
#[derive(Debug, Deserialize)]
struct ExtendedSearchResponse {
results: Vec<DiscogsSearchResultExtended>,
}
let search: ExtendedSearchResponse = response.json().await
.map_err(|e| format!("Failed to parse Discogs response: {}", e))?;
// Filter to releases only
let results: Vec<_> = search.results
.into_iter()
.filter(|r| r.result_type == "release" || r.result_type == "master")
.collect();
log::info!("Found {} Discogs releases", results.len());
Ok(results)
}
/// Get full release metadata including tracklist
pub async fn get_release_metadata(&self, release_id: u64) -> Result<DiscogsReleaseMetadata, String> {
let url = format!("{}/release/{}", DISCOGS_PROXY_URL, release_id);
log::debug!("Fetching Discogs release metadata for ID: {}", release_id);
let response = self.client.get(&url).send().await
.map_err(|e| format!("Failed to fetch release: {}", e))?;
if !response.status().is_success() {
return Err(format!("Failed to fetch release: {}", response.status()));
}
let metadata: DiscogsReleaseMetadata = response.json().await
.map_err(|e| format!("Failed to parse release metadata: {}", e))?;
log::info!("Fetched Discogs release: {} ({:?})", metadata.title, metadata.year);
Ok(metadata)
}
/// Download an image to the cache directory
async fn download_image(&self, image_url: &str, path: &Path) -> Option<()> {
log::debug!("Downloading Discogs artwork: {}", image_url);
+39
View File
@@ -200,6 +200,13 @@ pub fn run() {
// Initialize ListenBrainz integration state
let listenbrainz_state = listenbrainz::ListenBrainzSharedState::new()
.expect("Failed to initialize ListenBrainz state");
// Initialize remote metadata state (for Tag Editor service integration)
let remote_metadata_state = commands::RemoteMetadataSharedState {
inner: Arc::new(Mutex::new(library::remote_metadata::RemoteMetadataState::new(
Some(Arc::new(musicbrainz::MusicBrainzSharedState::new()
.expect("Failed to initialize MusicBrainz for remote metadata")))
))),
};
// Read saved audio device and settings for player initialization
let (saved_device, audio_settings) = audio_settings_state
@@ -390,6 +397,20 @@ pub fn run() {
log::info!("Close to tray: hiding window instead of closing");
let _ = window.hide();
api.prevent_close();
} else {
// Cleanup cast devices on actual close
log::info!("App closing: cleaning up cast devices");
// Disconnect Chromecast if connected (sends message through channel)
if let Some(cast_state) = window.app_handle().try_state::<cast::CastState>() {
log::info!("Disconnecting Chromecast on app exit");
cast_state.chromecast.disconnect();
}
// Note: DLNA connection will be dropped when the app exits,
// which will naturally close the connection. The tokio Mutex
// prevents us from synchronously stopping playback here.
log::info!("DLNA connection will be cleaned up on drop");
}
}
})
@@ -411,6 +432,7 @@ pub fn run() {
.manage(tray_settings_state)
.manage(musicbrainz_state)
.manage(listenbrainz_state)
.manage(remote_metadata_state)
.invoke_handler(tauri::generate_handler![
// Auth commands
commands::init_client,
@@ -574,6 +596,13 @@ pub fn run() {
library::commands::playlist_get_stats,
library::commands::playlist_get_all_stats,
library::commands::playlist_increment_play_count,
// Playlist custom order commands
library::commands::playlist_get_custom_order,
library::commands::playlist_init_custom_order,
library::commands::playlist_set_custom_order,
library::commands::playlist_move_track,
library::commands::playlist_has_custom_order,
library::commands::playlist_clear_custom_order,
// Playlist folders commands
library::commands::create_playlist_folder,
library::commands::get_playlist_folders,
@@ -597,6 +626,9 @@ pub fn run() {
// Album settings commands
library::commands::library_get_album_settings,
library::commands::library_set_album_hidden,
library::commands::library_update_album_metadata,
library::commands::library_write_album_metadata_to_files,
library::commands::library_refresh_album_metadata_from_files,
library::commands::library_get_hidden_albums,
library::commands::library_backfill_downloads,
// Artist images commands
@@ -619,6 +651,7 @@ pub fn run() {
cast::commands::cast_connect,
cast::commands::cast_disconnect,
cast::commands::cast_get_status,
cast::commands::cast_get_position,
cast::commands::cast_play_track,
cast::commands::cast_play_local_track,
cast::commands::cast_play,
@@ -633,6 +666,7 @@ pub fn run() {
cast::dlna::commands::dlna_connect,
cast::dlna::commands::dlna_disconnect,
cast::dlna::commands::dlna_get_status,
cast::dlna::commands::dlna_get_position,
cast::dlna::commands::dlna_play_track,
cast::dlna::commands::dlna_load_media,
cast::dlna::commands::dlna_play,
@@ -785,6 +819,11 @@ pub fn run() {
commands::smart_playlist_preview,
commands::smart_playlist_resolve_artist,
commands::smart_playlist_get_available_types,
// Remote metadata commands (Tag Editor service integration)
commands::remote_metadata_search,
commands::remote_metadata_get_album,
commands::remote_metadata_cache_stats,
commands::remote_metadata_clear_cache,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+565 -16
View File
@@ -1,10 +1,12 @@
//! Tauri commands for local library
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tauri::State;
use serde::Deserialize;
use tauri::{Emitter, State};
use tokio::sync::Mutex;
use crate::discogs::DiscogsClient;
@@ -69,7 +71,7 @@ pub async fn library_add_folder(
log::info!("Folder network info: is_network={}, fs_type={:?}", is_network, fs_type);
let db = state.db.lock().await;
let mut db = state.db.lock().await;
let id = db.add_folder_with_network_info(&path, is_network, fs_type.as_deref())
.map_err(|e| e.to_string())?;
@@ -86,7 +88,7 @@ pub async fn library_remove_folder(
) -> Result<(), String> {
log::info!("Command: library_remove_folder {}", path);
let db = state.db.lock().await;
let mut db = state.db.lock().await;
db.remove_folder(&path).map_err(|e| e.to_string())?;
db.delete_tracks_in_folder(&path).map_err(|e| e.to_string())?;
Ok(())
@@ -96,7 +98,7 @@ pub async fn library_remove_folder(
pub async fn library_get_folders(state: State<'_, LibraryState>) -> Result<Vec<String>, String> {
log::info!("Command: library_get_folders");
let db = state.db.lock().await;
let mut db = state.db.lock().await;
db.get_folders().map_err(|e| e.to_string())
}
@@ -108,7 +110,7 @@ pub async fn library_get_folders_with_metadata(
) -> Result<Vec<LibraryFolder>, String> {
log::info!("Command: library_get_folders_with_metadata");
let db = state.db.lock().await;
let mut db = state.db.lock().await;
let mut folders = db.get_folders_with_metadata().map_err(|e| e.to_string())?;
// Refresh network detection for folders without user override
@@ -163,7 +165,7 @@ pub async fn library_get_folder(
) -> Result<Option<LibraryFolder>, String> {
log::info!("Command: library_get_folder {}", id);
let db = state.db.lock().await;
let mut db = state.db.lock().await;
db.get_folder_by_id(id).map_err(|e| e.to_string())
}
@@ -180,7 +182,7 @@ pub async fn library_update_folder_settings(
) -> Result<LibraryFolder, String> {
log::info!("Command: library_update_folder_settings {} alias={:?} enabled={}", id, alias, enabled);
let db = state.db.lock().await;
let mut db = state.db.lock().await;
db.update_folder_settings(
id,
alias.as_deref(),
@@ -204,7 +206,7 @@ pub async fn library_set_folder_enabled(
) -> Result<(), String> {
log::info!("Command: library_set_folder_enabled {} enabled={}", id, enabled);
let db = state.db.lock().await;
let mut db = state.db.lock().await;
db.set_folder_enabled(id, enabled).map_err(|e| e.to_string())
}
@@ -226,7 +228,7 @@ pub async fn library_update_folder_path(
return Err("The selected path is not a folder".to_string());
}
let db = state.db.lock().await;
let mut db = state.db.lock().await;
db.update_folder_path(id, &new_path).map_err(|e| e.to_string())?;
// Check if it's a network folder and update network info
@@ -323,6 +325,7 @@ pub async fn library_scan(state: State<'_, LibraryState>) -> Result<(), String>
let scanner = LibraryScanner::new();
let mut all_errors: Vec<ScanError> = Vec::new();
let mut sidecar_cache: HashMap<String, Option<crate::library::AlbumTagSidecar>> = HashMap::new();
for folder in &folders {
log::info!("Scanning folder: {}", folder);
@@ -418,6 +421,7 @@ pub async fn library_scan(state: State<'_, LibraryState>) -> Result<(), String>
match MetadataExtractor::extract(&canonical_path) {
Ok(mut track) => {
apply_sidecar_override_if_present(&mut track, &mut sidecar_cache);
// Try to extract embedded artwork, fallback to cached folder artwork
let artwork_cache = get_artwork_cache_dir();
let mut artwork_path =
@@ -500,6 +504,22 @@ async fn process_cue_file(cue_path: &Path, state: &State<'_, LibraryState>) -> R
// Convert CUE to tracks
let mut tracks = cue_to_tracks(&cue, properties.duration_secs, format, &properties);
// Apply sidecar overrides if present (matches by file_path + cue_start_secs)
if let Some(group_key) = tracks
.first()
.map(|track| track.album_group_key.trim().to_string())
.filter(|k| !k.is_empty())
{
let album_dir = Path::new(&group_key);
if album_dir.is_dir() {
if let Ok(Some(sidecar)) = crate::library::read_album_sidecar(album_dir) {
for track in tracks.iter_mut() {
crate::library::apply_sidecar_to_track(track, &sidecar);
}
}
}
}
let artwork_cache = get_artwork_cache_dir();
let mut artwork_path = MetadataExtractor::extract_artwork(audio_path.as_path(), &artwork_cache);
if artwork_path.is_none() {
@@ -520,7 +540,7 @@ async fn process_cue_file(cue_path: &Path, state: &State<'_, LibraryState>) -> R
}
// Insert tracks
let db = state.db.lock().await;
let mut db = state.db.lock().await;
let group_key = tracks
.first()
.map(|track| track.album_group_key.clone())
@@ -537,6 +557,59 @@ async fn process_cue_file(cue_path: &Path, state: &State<'_, LibraryState>) -> R
Ok(())
}
fn apply_sidecar_override_if_present(
track: &mut LocalTrack,
cache: &mut HashMap<String, Option<crate::library::AlbumTagSidecar>>,
) {
let group_key = track.album_group_key.trim();
if group_key.is_empty() {
return;
}
let cached = cache.entry(group_key.to_string()).or_insert_with(|| {
let album_dir = Path::new(group_key);
if !album_dir.is_dir() {
return None;
}
match crate::library::read_album_sidecar(album_dir) {
Ok(sidecar) => sidecar,
Err(err) => {
log::warn!(
"Failed to read LocalLibrary sidecar for {}: {}",
album_dir.display(),
err
);
None
}
}
});
if let Some(sidecar) = cached.as_ref() {
crate::library::apply_sidecar_to_track(track, sidecar);
}
}
fn compute_track_artist_match(tracks: &[LocalTrack]) -> Option<String> {
let mut artists: HashSet<String> = HashSet::new();
for track in tracks {
let value = track
.album_artist
.as_deref()
.unwrap_or(track.artist.as_str())
.trim();
if value.is_empty() {
continue;
}
artists.insert(value.to_string());
if artists.len() > 1 {
return None;
}
}
artists.into_iter().next()
}
#[tauri::command]
pub async fn library_get_scan_progress(
state: State<'_, LibraryState>,
@@ -620,6 +693,7 @@ pub async fn library_scan_folder(
let scanner = LibraryScanner::new();
let mut all_errors: Vec<ScanError> = Vec::new();
let mut sidecar_cache: HashMap<String, Option<crate::library::AlbumTagSidecar>> = HashMap::new();
log::info!("Scanning single folder: {}", folder.path);
@@ -713,6 +787,7 @@ pub async fn library_scan_folder(
match MetadataExtractor::extract(&canonical_path) {
Ok(mut track) => {
apply_sidecar_override_if_present(&mut track, &mut sidecar_cache);
let artwork_cache = get_artwork_cache_dir();
let mut artwork_path = MetadataExtractor::extract_artwork(&canonical_path, &artwork_cache);
if artwork_path.is_none() {
@@ -796,7 +871,7 @@ pub async fn library_get_albums(
.map(|s| s.show_in_library)
.unwrap_or(false);
let db = state.db.lock().await;
let mut db = state.db.lock().await;
// Use optimized SQL-based filtering instead of N+1 query pattern
let albums = db.get_albums_with_full_filter(
@@ -816,7 +891,7 @@ pub async fn library_get_album_tracks(
) -> Result<Vec<LocalTrack>, String> {
log::info!("Command: library_get_album_tracks {}", album_group_key);
let db = state.db.lock().await;
let mut db = state.db.lock().await;
db.get_album_tracks(&album_group_key)
.map_err(|e| e.to_string())
}
@@ -837,7 +912,7 @@ pub async fn library_get_artists(
.map(|s| s.show_in_library)
.unwrap_or(false);
let db = state.db.lock().await;
let mut db = state.db.lock().await;
// Use optimized SQL-based filtering instead of N+1 query pattern
let artists = db.get_artists_with_filter(
@@ -867,7 +942,7 @@ pub async fn library_search(
.map(|s| s.show_in_library)
.unwrap_or(false);
let db = state.db.lock().await;
let mut db = state.db.lock().await;
// Use optimized SQL-based filtering
// limit = 0 means no limit (fetch all tracks)
@@ -886,7 +961,7 @@ pub async fn library_search(
pub async fn library_get_stats(state: State<'_, LibraryState>) -> Result<LibraryStats, String> {
log::info!("Command: library_get_stats");
let db = state.db.lock().await;
let mut db = state.db.lock().await;
db.get_stats().map_err(|e| e.to_string())
}
@@ -894,7 +969,7 @@ pub async fn library_get_stats(state: State<'_, LibraryState>) -> Result<Library
pub async fn library_clear(state: State<'_, LibraryState>) -> Result<(), String> {
log::info!("Command: library_clear");
let db = state.db.lock().await;
let mut db = state.db.lock().await;
db.clear_all_tracks().map_err(|e| e.to_string())
}
@@ -1263,6 +1338,92 @@ pub async fn playlist_increment_play_count(
.map_err(|e| e.to_string())
}
// === Playlist Custom Track Order ===
/// Get custom track order for a playlist
/// Returns Vec of (track_id, is_local, position)
#[tauri::command]
pub async fn playlist_get_custom_order(
playlist_id: u64,
state: State<'_, LibraryState>,
) -> Result<Vec<(i64, bool, i32)>, String> {
log::info!("Command: playlist_get_custom_order {}", playlist_id);
let db = state.db.lock().await;
db.get_playlist_custom_order(playlist_id)
.map_err(|e| e.to_string())
}
/// Initialize custom order for a playlist from current track arrangement
#[tauri::command]
pub async fn playlist_init_custom_order(
playlist_id: u64,
track_ids: Vec<(i64, bool)>, // (track_id, is_local)
state: State<'_, LibraryState>,
) -> Result<(), String> {
log::info!("Command: playlist_init_custom_order {} ({} tracks)", playlist_id, track_ids.len());
let db = state.db.lock().await;
db.init_playlist_custom_order(playlist_id, &track_ids)
.map_err(|e| e.to_string())
}
/// Set entire custom order for a playlist (batch update)
#[tauri::command]
pub async fn playlist_set_custom_order(
playlist_id: u64,
orders: Vec<(i64, bool, i32)>, // (track_id, is_local, position)
state: State<'_, LibraryState>,
) -> Result<(), String> {
log::info!("Command: playlist_set_custom_order {} ({} tracks)", playlist_id, orders.len());
let db = state.db.lock().await;
db.set_playlist_custom_order(playlist_id, &orders)
.map_err(|e| e.to_string())
}
/// Move a single track to a new position
#[tauri::command]
pub async fn playlist_move_track(
playlist_id: u64,
track_id: i64,
is_local: bool,
new_position: i32,
state: State<'_, LibraryState>,
) -> Result<(), String> {
log::info!("Command: playlist_move_track {} track {} -> pos {}", playlist_id, track_id, new_position);
let db = state.db.lock().await;
db.move_playlist_track(playlist_id, track_id, is_local, new_position)
.map_err(|e| e.to_string())
}
/// Check if a playlist has custom order defined
#[tauri::command]
pub async fn playlist_has_custom_order(
playlist_id: u64,
state: State<'_, LibraryState>,
) -> Result<bool, String> {
log::info!("Command: playlist_has_custom_order {}", playlist_id);
let db = state.db.lock().await;
db.has_playlist_custom_order(playlist_id)
.map_err(|e| e.to_string())
}
/// Clear custom order for a playlist
#[tauri::command]
pub async fn playlist_clear_custom_order(
playlist_id: u64,
state: State<'_, LibraryState>,
) -> Result<(), String> {
log::info!("Command: playlist_clear_custom_order {}", playlist_id);
let db = state.db.lock().await;
db.clear_playlist_custom_order(playlist_id)
.map_err(|e| e.to_string())
}
// === Discogs Artwork ===
/// Check if Discogs credentials are configured (proxy handles credentials)
@@ -1418,6 +1579,394 @@ pub async fn library_set_album_hidden(
.map_err(|e| e.to_string())
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LibraryAlbumTrackMetadataUpdate {
pub id: i64,
pub file_path: String,
pub cue_start_secs: Option<f64>,
pub title: String,
pub disc_number: Option<u32>,
pub track_number: Option<u32>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LibraryAlbumMetadataUpdateRequest {
pub album_group_key: String,
pub album_title: String,
pub album_artist: String,
pub year: Option<u32>,
pub genre: Option<String>,
pub catalog_number: Option<String>,
pub tracks: Vec<LibraryAlbumTrackMetadataUpdate>,
}
#[tauri::command]
pub async fn library_update_album_metadata(
request: LibraryAlbumMetadataUpdateRequest,
state: State<'_, LibraryState>,
) -> Result<(), String> {
log::info!(
"Command: library_update_album_metadata {}",
request.album_group_key
);
if request.album_group_key.trim().is_empty() {
return Err("Album ID is required.".to_string());
}
if request.album_title.trim().is_empty() {
return Err("Album title is required.".to_string());
}
if request.tracks.is_empty() {
return Err("Album track list is empty.".to_string());
}
let album_dir = PathBuf::from(request.album_group_key.trim());
if !album_dir.is_dir() {
return Err("Album folder not found on disk.".to_string());
}
// Write sidecar first (persistence), then update DB.
let sidecar_result = tokio::task::spawn_blocking({
let album_dir = album_dir.clone();
let request = request.clone();
move || -> Result<(), String> {
let album = crate::library::AlbumMetadataOverride {
album_title: Some(request.album_title.trim().to_string()),
album_artist: Some(request.album_artist.trim().to_string())
.filter(|s| !s.is_empty()),
year: request.year,
genre: request.genre.as_ref().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()),
catalog_number: request
.catalog_number
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
};
let tracks = request
.tracks
.iter()
.map(|t| crate::library::TrackMetadataOverride {
file_path: t.file_path.clone(),
cue_start_secs: t.cue_start_secs,
title: Some(t.title.trim().to_string()),
disc_number: t.disc_number,
track_number: t.track_number,
})
.collect::<Vec<_>>();
let sidecar = crate::library::AlbumTagSidecar::new(album, tracks);
crate::library::write_album_sidecar(&album_dir, &sidecar)
.map_err(|e| format!("Failed to write sidecar: {}", e))?;
Ok(())
}
})
.await
.map_err(|e| format!("Failed to write sidecar: {}", e))?;
sidecar_result?;
let mut db = state.db.lock().await;
let existing_tracks = db
.get_album_tracks(&request.album_group_key)
.map_err(|e| e.to_string())?;
let track_artist_match = compute_track_artist_match(&existing_tracks);
let track_updates = request
.tracks
.iter()
.map(|t| crate::library::AlbumTrackUpdate {
id: t.id,
title: t.title.clone(),
disc_number: t.disc_number,
track_number: t.track_number,
})
.collect::<Vec<_>>();
db.update_album_group_metadata(
&request.album_group_key,
&request.album_title,
&request.album_artist,
request.year,
request.genre.as_deref(),
request.catalog_number.as_deref(),
track_artist_match.as_deref(),
&track_updates,
)
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn library_write_album_metadata_to_files(
app: tauri::AppHandle,
request: LibraryAlbumMetadataUpdateRequest,
state: State<'_, LibraryState>,
) -> Result<(), String> {
log::info!(
"Command: library_write_album_metadata_to_files {}",
request.album_group_key
);
if request.album_group_key.trim().is_empty() {
return Err("Album ID is required.".to_string());
}
if request.album_title.trim().is_empty() {
return Err("Album title is required.".to_string());
}
if request.tracks.is_empty() {
return Err("Album track list is empty.".to_string());
}
let db = state.db.lock().await;
let existing_tracks = db
.get_album_tracks(&request.album_group_key)
.map_err(|e| e.to_string())?;
if existing_tracks
.iter()
.any(|t| t.cue_file_path.is_some() || t.cue_start_secs.is_some())
{
return Err("Writing tags to files is not supported for CUE-based albums. Use sidecar mode instead.".to_string());
}
drop(db);
let album_dir = PathBuf::from(request.album_group_key.trim());
if !album_dir.is_dir() {
return Err("Album folder not found on disk.".to_string());
}
// Write embedded tags for each track file.
let write_result = tokio::task::spawn_blocking({
let request = request.clone();
move || -> Result<(), String> {
use lofty::{Accessor, AudioFile, ItemKey, Tag, TagExt, TaggedFileExt, TagType};
// Ensure we only write each file once.
let mut by_file: HashMap<String, &LibraryAlbumTrackMetadataUpdate> = HashMap::new();
for track in &request.tracks {
by_file.entry(track.file_path.clone()).or_insert(track);
}
let total = by_file.len();
let mut current = 0usize;
for (file_path, track) in by_file {
current += 1;
// Emit progress event
let _ = app.emit("library:tag_write_progress", serde_json::json!({
"current": current,
"total": total
}));
let path = Path::new(&file_path);
if !path.is_file() {
return Err("One or more audio files were not found on disk.".to_string());
}
let mut tagged_file =
lofty::read_from_path(path).map_err(|_| "Failed to read audio file tags.".to_string())?;
let primary_type = tagged_file.primary_tag_type();
if tagged_file.primary_tag_mut().is_none() && tagged_file.first_tag_mut().is_none() {
tagged_file.insert_tag(Tag::new(primary_type));
}
{
let tag = if let Some(tag) = tagged_file.primary_tag_mut() {
tag
} else if let Some(tag) = tagged_file.first_tag_mut() {
tag
} else {
return Err("Failed to access audio file tags.".to_string());
};
tag.set_title(track.title.trim().to_string());
tag.set_album(request.album_title.trim().to_string());
tag.set_artist(request.album_artist.trim().to_string());
if let Some(no) = track.track_number {
tag.set_track(no);
}
if let Some(disc) = track.disc_number {
tag.set_disk(disc);
}
// Album artist (not part of Accessor).
if request.album_artist.trim().is_empty() {
tag.remove_key(&ItemKey::AlbumArtist);
} else {
tag.insert_text(ItemKey::AlbumArtist, request.album_artist.trim().to_string());
}
// Year / Genre
if let Some(year) = request.year {
tag.set_year(year);
} else {
tag.remove_year();
}
if let Some(ref genre) = request.genre {
let g = genre.trim();
if g.is_empty() {
tag.remove_genre();
} else {
tag.set_genre(g.to_string());
}
} else {
tag.remove_genre();
}
if let Some(ref cat) = request.catalog_number {
let c = cat.trim();
if c.is_empty() {
tag.remove_key(&ItemKey::CatalogNumber);
} else {
tag.insert_text(ItemKey::CatalogNumber, c.to_string());
}
} else {
tag.remove_key(&ItemKey::CatalogNumber);
}
}
tagged_file
.save_to_path(path)
.map_err(|_| "Failed to write tags to audio files. Check that the album folder is mounted read-write and you have permissions.".to_string())?;
}
Ok(())
}
})
.await
.map_err(|e| format!("Failed to write tags: {}", e))?;
write_result?;
// Remove sidecar (direct-edit mode disables sidecar persistence).
let _ = tokio::task::spawn_blocking({
let album_dir = album_dir.clone();
move || crate::library::delete_album_sidecar(&album_dir)
})
.await;
// Update DB from the requested values.
let mut db = state.db.lock().await;
let existing_tracks = db
.get_album_tracks(&request.album_group_key)
.map_err(|e| e.to_string())?;
let track_artist_match = compute_track_artist_match(&existing_tracks);
let track_updates = request
.tracks
.iter()
.map(|t| crate::library::AlbumTrackUpdate {
id: t.id,
title: t.title.clone(),
disc_number: t.disc_number,
track_number: t.track_number,
})
.collect::<Vec<_>>();
db.update_album_group_metadata(
&request.album_group_key,
&request.album_title,
&request.album_artist,
request.year,
request.genre.as_deref(),
request.catalog_number.as_deref(),
track_artist_match.as_deref(),
&track_updates,
)
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn library_refresh_album_metadata_from_files(
album_group_key: String,
state: State<'_, LibraryState>,
) -> Result<(), String> {
log::info!(
"Command: library_refresh_album_metadata_from_files {}",
album_group_key
);
if album_group_key.trim().is_empty() {
return Err("Album ID is required.".to_string());
}
let db = state.db.lock().await;
let existing_tracks = db.get_album_tracks(&album_group_key).map_err(|e| e.to_string())?;
if existing_tracks.is_empty() {
return Err("Album not found.".to_string());
}
if existing_tracks
.iter()
.any(|t| t.cue_file_path.is_some() || t.cue_start_secs.is_some())
{
return Err(
"Refreshing metadata from files is not supported for CUE-based albums.".to_string(),
);
}
drop(db);
let album_dir = PathBuf::from(album_group_key.trim());
if !album_dir.is_dir() {
return Err("Album folder not found on disk.".to_string());
}
// Delete sidecar, then refresh DB from embedded tags.
let refresh = tokio::task::spawn_blocking({
let existing_tracks = existing_tracks.clone();
let album_dir = album_dir.clone();
move || -> Result<Vec<crate::library::TrackMetadataUpdateFull>, String> {
let _ = crate::library::delete_album_sidecar(&album_dir);
let mut updates: Vec<crate::library::TrackMetadataUpdateFull> = Vec::new();
for track in existing_tracks {
let path = Path::new(&track.file_path);
if !path.is_file() {
return Err("One or more audio files were not found on disk.".to_string());
}
let extracted =
MetadataExtractor::extract(path).map_err(|_| "Failed to read audio file tags.".to_string())?;
let album_group_title = if extracted.album_group_title.trim().is_empty() {
extracted.album.clone()
} else {
extracted.album_group_title.clone()
};
updates.push(crate::library::TrackMetadataUpdateFull {
id: track.id,
title: extracted.title,
artist: extracted.artist,
album: extracted.album,
album_artist: extracted.album_artist,
album_group_title,
track_number: extracted.track_number,
disc_number: extracted.disc_number,
year: extracted.year,
genre: extracted.genre,
catalog_number: extracted.catalog_number,
});
}
Ok(updates)
}
})
.await
.map_err(|e| format!("Failed to refresh metadata: {}", e))?;
let updates = refresh?;
let mut db = state.db.lock().await;
db.update_tracks_metadata_by_id(&updates)
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn library_get_hidden_albums(
state: State<'_, LibraryState>,
+407
View File
@@ -5,6 +5,29 @@ use std::path::Path;
use crate::library::{AudioFormat, LibraryError, LocalAlbum, LocalArtist, LocalTrack};
#[derive(Debug, Clone)]
pub struct AlbumTrackUpdate {
pub id: i64,
pub title: String,
pub disc_number: Option<u32>,
pub track_number: Option<u32>,
}
#[derive(Debug, Clone)]
pub struct TrackMetadataUpdateFull {
pub id: i64,
pub title: String,
pub artist: String,
pub album: String,
pub album_artist: Option<String>,
pub album_group_title: String,
pub track_number: Option<u32>,
pub disc_number: Option<u32>,
pub year: Option<u32>,
pub genre: Option<String>,
pub catalog_number: Option<String>,
}
/// Library database wrapper
pub struct LibraryDatabase {
conn: Connection,
@@ -132,6 +155,23 @@ impl LibraryDatabase {
CREATE INDEX IF NOT EXISTS idx_playlist_local_tracks_playlist
ON playlist_local_tracks(qobuz_playlist_id);
-- Custom track order per playlist (user-defined arrangement)
CREATE TABLE IF NOT EXISTS playlist_track_custom_order (
id INTEGER PRIMARY KEY,
qobuz_playlist_id INTEGER NOT NULL,
track_id INTEGER NOT NULL,
is_local INTEGER DEFAULT 0,
custom_position INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(qobuz_playlist_id, track_id, is_local)
);
CREATE INDEX IF NOT EXISTS idx_playlist_custom_order_playlist
ON playlist_track_custom_order(qobuz_playlist_id);
CREATE INDEX IF NOT EXISTS idx_playlist_custom_order_position
ON playlist_track_custom_order(qobuz_playlist_id, custom_position);
-- Album settings (per-album customization)
CREATE TABLE IF NOT EXISTS album_settings (
album_group_key TEXT PRIMARY KEY,
@@ -577,6 +617,36 @@ impl LibraryDatabase {
"CREATE INDEX IF NOT EXISTS idx_playlist_settings_folder ON playlist_settings(folder_id);"
).map_err(|e| LibraryError::Database(format!("Failed to create folder index: {}", e)))?;
// Migration: Create playlist_track_custom_order table for custom track arrangement
let has_custom_order_table: bool = self.conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='playlist_track_custom_order'",
[],
|row| row.get::<_, i32>(0),
)
.map(|count| count > 0)
.unwrap_or(false);
if !has_custom_order_table {
log::info!("Running migration: creating playlist_track_custom_order table");
self.conn.execute_batch(
"CREATE TABLE IF NOT EXISTS playlist_track_custom_order (
id INTEGER PRIMARY KEY,
qobuz_playlist_id INTEGER NOT NULL,
track_id INTEGER NOT NULL,
is_local INTEGER DEFAULT 0,
custom_position INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(qobuz_playlist_id, track_id, is_local)
);
CREATE INDEX IF NOT EXISTS idx_playlist_custom_order_playlist
ON playlist_track_custom_order(qobuz_playlist_id);
CREATE INDEX IF NOT EXISTS idx_playlist_custom_order_position
ON playlist_track_custom_order(qobuz_playlist_id, custom_position);"
).map_err(|e| LibraryError::Database(format!("Migration failed: {}", e)))?;
}
Ok(())
}
@@ -1250,6 +1320,145 @@ impl LibraryDatabase {
Ok(())
}
pub fn update_album_group_metadata(
&mut self,
group_key: &str,
album_title: &str,
album_artist: &str,
year: Option<u32>,
genre: Option<&str>,
catalog_number: Option<&str>,
track_artist_match: Option<&str>,
track_updates: &[AlbumTrackUpdate],
) -> Result<(), LibraryError> {
let tx = self
.conn
.transaction()
.map_err(|e| LibraryError::Database(e.to_string()))?;
let normalized_album_artist = {
let trimmed = album_artist.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
};
tx.execute(
r#"
UPDATE local_tracks
SET
album = ?1,
album_group_title = ?2,
album_artist = ?3,
year = ?4,
genre = ?5,
catalog_number = ?6
WHERE COALESCE(album_group_key, album || '|' || COALESCE(album_artist, artist)) = ?7
"#,
params![
album_title.trim(),
album_title.trim(),
normalized_album_artist,
year,
genre.map(|s| s.trim()).filter(|s| !s.is_empty()),
catalog_number.map(|s| s.trim()).filter(|s| !s.is_empty()),
group_key
],
)
.map_err(|e| LibraryError::Database(e.to_string()))?;
if let Some(match_artist) = track_artist_match {
let match_trim = match_artist.trim();
if !match_trim.is_empty() && !album_artist.trim().is_empty() {
tx.execute(
r#"
UPDATE local_tracks
SET artist = ?1
WHERE COALESCE(album_group_key, album || '|' || COALESCE(album_artist, artist)) = ?2
AND artist = ?3
"#,
params![album_artist.trim(), group_key, match_trim],
)
.map_err(|e| LibraryError::Database(e.to_string()))?;
}
}
{
let mut stmt = tx
.prepare("UPDATE local_tracks SET title = ?1, disc_number = ?2, track_number = ?3 WHERE id = ?4")
.map_err(|e| LibraryError::Database(e.to_string()))?;
for update in track_updates {
stmt.execute(params![
update.title.trim(),
update.disc_number,
update.track_number,
update.id
])
.map_err(|e| LibraryError::Database(e.to_string()))?;
}
}
tx.commit()
.map_err(|e| LibraryError::Database(e.to_string()))?;
Ok(())
}
pub fn update_tracks_metadata_by_id(
&mut self,
updates: &[TrackMetadataUpdateFull],
) -> Result<(), LibraryError> {
let tx = self
.conn
.transaction()
.map_err(|e| LibraryError::Database(e.to_string()))?;
{
let mut stmt = tx
.prepare(
r#"
UPDATE local_tracks
SET
title = ?1,
artist = ?2,
album = ?3,
album_artist = ?4,
album_group_title = ?5,
track_number = ?6,
disc_number = ?7,
year = ?8,
genre = ?9,
catalog_number = ?10
WHERE id = ?11
"#,
)
.map_err(|e| LibraryError::Database(e.to_string()))?;
for update in updates {
stmt.execute(params![
update.title.trim(),
update.artist.trim(),
update.album.trim(),
update.album_artist.as_ref().map(|s| s.trim().to_string()),
update.album_group_title.trim(),
update.track_number,
update.disc_number,
update.year,
update.genre.as_ref().map(|s| s.trim().to_string()),
update.catalog_number.as_ref().map(|s| s.trim().to_string()),
update.id
])
.map_err(|e| LibraryError::Database(e.to_string()))?;
}
}
tx.commit()
.map_err(|e| LibraryError::Database(e.to_string()))?;
Ok(())
}
pub fn find_album_group_key(
&self,
album: &str,
@@ -2367,6 +2576,204 @@ impl LibraryDatabase {
Ok(())
}
// === Playlist Custom Track Order ===
/// Get custom track order for a playlist
/// Returns Vec of (track_id, is_local, custom_position)
pub fn get_playlist_custom_order(&self, qobuz_playlist_id: u64) -> Result<Vec<(i64, bool, i32)>, LibraryError> {
let mut stmt = self.conn.prepare(
"SELECT track_id, is_local, custom_position
FROM playlist_track_custom_order
WHERE qobuz_playlist_id = ?1
ORDER BY custom_position ASC"
).map_err(|e| LibraryError::Database(format!("Failed to prepare custom order query: {}", e)))?;
let rows = stmt.query_map(params![qobuz_playlist_id as i64], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, i32>(1)? != 0,
row.get::<_, i32>(2)?
))
}).map_err(|e| LibraryError::Database(format!("Failed to query custom order: {}", e)))?;
let mut result = Vec::new();
for row in rows {
result.push(row.map_err(|e| LibraryError::Database(format!("Failed to read custom order row: {}", e)))?);
}
Ok(result)
}
/// Initialize custom order for a playlist from a list of track IDs
/// This sets up the initial order based on the current track arrangement
pub fn init_playlist_custom_order(
&self,
qobuz_playlist_id: u64,
track_ids: &[(i64, bool)], // (track_id, is_local)
) -> Result<(), LibraryError> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
// Clear existing custom order
self.conn.execute(
"DELETE FROM playlist_track_custom_order WHERE qobuz_playlist_id = ?1",
params![qobuz_playlist_id as i64],
).map_err(|e| LibraryError::Database(format!("Failed to clear existing custom order: {}", e)))?;
// Insert new order
let mut stmt = self.conn.prepare(
"INSERT INTO playlist_track_custom_order
(qobuz_playlist_id, track_id, is_local, custom_position, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
).map_err(|e| LibraryError::Database(format!("Failed to prepare custom order insert: {}", e)))?;
for (position, (track_id, is_local)) in track_ids.iter().enumerate() {
stmt.execute(params![
qobuz_playlist_id as i64,
*track_id,
*is_local as i32,
position as i32,
now,
now,
]).map_err(|e| LibraryError::Database(format!("Failed to insert custom order: {}", e)))?;
}
Ok(())
}
/// Set entire custom order for a playlist (batch update)
pub fn set_playlist_custom_order(
&self,
qobuz_playlist_id: u64,
orders: &[(i64, bool, i32)], // (track_id, is_local, position)
) -> Result<(), LibraryError> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
// Clear existing custom order
self.conn.execute(
"DELETE FROM playlist_track_custom_order WHERE qobuz_playlist_id = ?1",
params![qobuz_playlist_id as i64],
).map_err(|e| LibraryError::Database(format!("Failed to clear existing custom order: {}", e)))?;
// Insert new order
let mut stmt = self.conn.prepare(
"INSERT INTO playlist_track_custom_order
(qobuz_playlist_id, track_id, is_local, custom_position, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
).map_err(|e| LibraryError::Database(format!("Failed to prepare custom order insert: {}", e)))?;
for (track_id, is_local, position) in orders {
stmt.execute(params![
qobuz_playlist_id as i64,
*track_id,
*is_local as i32,
*position,
now,
now,
]).map_err(|e| LibraryError::Database(format!("Failed to insert custom order: {}", e)))?;
}
Ok(())
}
/// Move a single track to a new position (reorders other tracks accordingly)
pub fn move_playlist_track(
&self,
qobuz_playlist_id: u64,
track_id: i64,
is_local: bool,
new_position: i32,
) -> Result<(), LibraryError> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
// Get current position of the track
let current_position: Option<i32> = self.conn.query_row(
"SELECT custom_position FROM playlist_track_custom_order
WHERE qobuz_playlist_id = ?1 AND track_id = ?2 AND is_local = ?3",
params![qobuz_playlist_id as i64, track_id, is_local as i32],
|row| row.get(0),
).ok();
let current_position = match current_position {
Some(pos) => pos,
None => {
// Track not in custom order yet, just insert it
self.conn.execute(
"INSERT INTO playlist_track_custom_order
(qobuz_playlist_id, track_id, is_local, custom_position, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![qobuz_playlist_id as i64, track_id, is_local as i32, new_position, now, now],
).map_err(|e| LibraryError::Database(format!("Failed to insert track position: {}", e)))?;
return Ok(());
}
};
if current_position == new_position {
return Ok(());
}
// Shift other tracks to make room
if new_position < current_position {
// Moving up: shift tracks between new_position and current_position down
self.conn.execute(
"UPDATE playlist_track_custom_order
SET custom_position = custom_position + 1, updated_at = ?4
WHERE qobuz_playlist_id = ?1
AND custom_position >= ?2
AND custom_position < ?3",
params![qobuz_playlist_id as i64, new_position, current_position, now],
).map_err(|e| LibraryError::Database(format!("Failed to shift tracks: {}", e)))?;
} else {
// Moving down: shift tracks between current_position and new_position up
self.conn.execute(
"UPDATE playlist_track_custom_order
SET custom_position = custom_position - 1, updated_at = ?4
WHERE qobuz_playlist_id = ?1
AND custom_position > ?2
AND custom_position <= ?3",
params![qobuz_playlist_id as i64, current_position, new_position, now],
).map_err(|e| LibraryError::Database(format!("Failed to shift tracks: {}", e)))?;
}
// Update the track's position
self.conn.execute(
"UPDATE playlist_track_custom_order
SET custom_position = ?3, updated_at = ?5
WHERE qobuz_playlist_id = ?1 AND track_id = ?2 AND is_local = ?4",
params![qobuz_playlist_id as i64, track_id, new_position, is_local as i32, now],
).map_err(|e| LibraryError::Database(format!("Failed to update track position: {}", e)))?;
Ok(())
}
/// Check if a playlist has custom order defined
pub fn has_playlist_custom_order(&self, qobuz_playlist_id: u64) -> Result<bool, LibraryError> {
let count: i32 = self.conn.query_row(
"SELECT COUNT(*) FROM playlist_track_custom_order WHERE qobuz_playlist_id = ?1",
params![qobuz_playlist_id as i64],
|row| row.get(0),
).map_err(|e| LibraryError::Database(format!("Failed to check custom order: {}", e)))?;
Ok(count > 0)
}
/// Clear custom order for a playlist
pub fn clear_playlist_custom_order(&self, qobuz_playlist_id: u64) -> Result<(), LibraryError> {
self.conn.execute(
"DELETE FROM playlist_track_custom_order WHERE qobuz_playlist_id = ?1",
params![qobuz_playlist_id as i64],
).map_err(|e| LibraryError::Database(format!("Failed to clear custom order: {}", e)))?;
Ok(())
}
// === Album Settings ===
/// Get album settings
+7 -1
View File
@@ -9,15 +9,21 @@ pub mod database;
pub mod errors;
pub mod metadata;
pub mod models;
pub mod remote_metadata;
pub mod scanner;
pub mod tag_sidecar;
pub mod thumbnails;
pub use commands::LibraryState;
pub use cue_parser::{cue_to_tracks, CueParser, CueSheet, CueTrack};
pub use database::{LibraryDatabase, LibraryFolder, LibraryStats, PlaylistFolder, PlaylistSettings, PlaylistStats};
pub use database::{
AlbumTrackUpdate, LibraryDatabase, LibraryFolder, LibraryStats, PlaylistFolder, PlaylistSettings,
PlaylistStats, TrackMetadataUpdateFull,
};
pub use errors::LibraryError;
pub use metadata::MetadataExtractor;
pub use models::*;
pub use tag_sidecar::*;
pub use scanner::{LibraryScanner, ScanResult};
use std::path::PathBuf;
@@ -0,0 +1,225 @@
//! In-memory cache for remote metadata requests
//!
//! Provides short-lived caching to avoid redundant API calls during
//! a tag editing session.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use super::models::{RemoteAlbumMetadata, RemoteAlbumSearchResult, RemoteProvider};
/// Cache entry with expiration
#[derive(Debug, Clone)]
struct CacheEntry<T> {
data: T,
inserted_at: Instant,
ttl: Duration,
}
impl<T> CacheEntry<T> {
fn new(data: T, ttl: Duration) -> Self {
Self {
data,
inserted_at: Instant::now(),
ttl,
}
}
fn is_expired(&self) -> bool {
self.inserted_at.elapsed() > self.ttl
}
}
/// Cache key for search results
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
struct SearchCacheKey {
provider: String,
query: String,
catalog_id: Option<String>,
artist: Option<String>,
}
/// Cache key for full metadata
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
struct MetadataCacheKey {
provider: String,
provider_id: String,
}
/// Remote metadata cache
pub struct RemoteMetadataCache {
/// Search results cache (short TTL: 10 minutes)
search_cache: Arc<RwLock<HashMap<SearchCacheKey, CacheEntry<Vec<RemoteAlbumSearchResult>>>>>,
/// Full metadata cache (longer TTL: 1 hour)
metadata_cache: Arc<RwLock<HashMap<MetadataCacheKey, CacheEntry<RemoteAlbumMetadata>>>>,
/// TTL for search results
search_ttl: Duration,
/// TTL for full metadata
metadata_ttl: Duration,
}
impl Default for RemoteMetadataCache {
fn default() -> Self {
Self::new()
}
}
impl RemoteMetadataCache {
/// Create a new cache with default TTLs
pub fn new() -> Self {
Self {
search_cache: Arc::new(RwLock::new(HashMap::new())),
metadata_cache: Arc::new(RwLock::new(HashMap::new())),
search_ttl: Duration::from_secs(10 * 60), // 10 minutes
metadata_ttl: Duration::from_secs(60 * 60), // 1 hour
}
}
/// Get cached search results
pub async fn get_search(
&self,
provider: RemoteProvider,
query: &str,
catalog_id: Option<&str>,
artist: Option<&str>,
) -> Option<Vec<RemoteAlbumSearchResult>> {
let key = SearchCacheKey {
provider: provider.to_string(),
query: query.to_lowercase(),
catalog_id: catalog_id.map(|s| s.to_lowercase()),
artist: artist.map(|s| s.to_lowercase()),
};
let cache = self.search_cache.read().await;
if let Some(entry) = cache.get(&key) {
if !entry.is_expired() {
log::debug!(
"Cache hit for search: {} {:?}",
provider,
query
);
return Some(entry.data.clone());
}
}
None
}
/// Store search results in cache
pub async fn set_search(
&self,
provider: RemoteProvider,
query: &str,
catalog_id: Option<&str>,
artist: Option<&str>,
results: Vec<RemoteAlbumSearchResult>,
) {
let key = SearchCacheKey {
provider: provider.to_string(),
query: query.to_lowercase(),
catalog_id: catalog_id.map(|s| s.to_lowercase()),
artist: artist.map(|s| s.to_lowercase()),
};
let mut cache = self.search_cache.write().await;
cache.insert(key, CacheEntry::new(results, self.search_ttl));
}
/// Get cached full metadata
pub async fn get_metadata(
&self,
provider: RemoteProvider,
provider_id: &str,
) -> Option<RemoteAlbumMetadata> {
let key = MetadataCacheKey {
provider: provider.to_string(),
provider_id: provider_id.to_string(),
};
let cache = self.metadata_cache.read().await;
if let Some(entry) = cache.get(&key) {
if !entry.is_expired() {
log::debug!(
"Cache hit for metadata: {} {}",
provider,
provider_id
);
return Some(entry.data.clone());
}
}
None
}
/// Store full metadata in cache
pub async fn set_metadata(
&self,
provider: RemoteProvider,
provider_id: &str,
metadata: RemoteAlbumMetadata,
) {
let key = MetadataCacheKey {
provider: provider.to_string(),
provider_id: provider_id.to_string(),
};
let mut cache = self.metadata_cache.write().await;
cache.insert(key, CacheEntry::new(metadata, self.metadata_ttl));
}
/// Clear expired entries from both caches
pub async fn cleanup_expired(&self) -> usize {
let mut removed = 0;
{
let mut cache = self.search_cache.write().await;
let before = cache.len();
cache.retain(|_, entry| !entry.is_expired());
removed += before - cache.len();
}
{
let mut cache = self.metadata_cache.write().await;
let before = cache.len();
cache.retain(|_, entry| !entry.is_expired());
removed += before - cache.len();
}
if removed > 0 {
log::debug!("Cleaned up {} expired cache entries", removed);
}
removed
}
/// Clear all cached data
pub async fn clear_all(&self) {
{
let mut cache = self.search_cache.write().await;
cache.clear();
}
{
let mut cache = self.metadata_cache.write().await;
cache.clear();
}
log::info!("Cleared remote metadata cache");
}
/// Get cache statistics
pub async fn stats(&self) -> CacheStats {
let search_count = self.search_cache.read().await.len();
let metadata_count = self.metadata_cache.read().await.len();
CacheStats {
search_entries: search_count,
metadata_entries: metadata_count,
}
}
}
/// Cache statistics
#[derive(Debug, Clone, serde::Serialize)]
pub struct CacheStats {
pub search_entries: usize,
pub metadata_entries: usize,
}
@@ -0,0 +1,589 @@
//! Remote metadata fetching for the Tag Editor
//!
//! This module provides a unified interface for fetching album metadata
//! from MusicBrainz and Discogs. The Tag Editor uses these services to
//! help users fill in metadata for their local library albums.
//!
//! # Providers
//!
//! - **MusicBrainz** (default): Free, community-maintained database.
//! No authentication required. Uses tags for genres.
//!
//! - **Discogs**: Commercial database with extensive catalog.
//! Requires API credentials (handled via proxy). Has genres and styles.
//!
//! # Usage
//!
//! 1. Search for albums using `search_albums()`
//! 2. Display results to user
//! 3. Fetch full metadata using `get_album_metadata()`
//! 4. Apply to editor form
mod cache;
mod models;
pub use cache::{CacheStats, RemoteMetadataCache};
pub use models::{
RemoteAlbumMetadata, RemoteAlbumSearchResult, RemoteMetadataError, RemoteProvider,
RemoteSearchRequest, RemoteSearchResponse, RemoteTrackMetadata,
};
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::discogs::DiscogsClient;
use crate::musicbrainz::MusicBrainzSharedState;
/// Shared state for remote metadata operations
pub struct RemoteMetadataState {
/// In-memory cache
pub cache: RemoteMetadataCache,
/// MusicBrainz client (shared with existing integration)
pub musicbrainz: Option<Arc<MusicBrainzSharedState>>,
/// Discogs client
pub discogs: Arc<Mutex<DiscogsClient>>,
}
impl RemoteMetadataState {
pub fn new(musicbrainz: Option<Arc<MusicBrainzSharedState>>) -> Self {
Self {
cache: RemoteMetadataCache::new(),
musicbrainz,
discogs: Arc::new(Mutex::new(DiscogsClient::new())),
}
}
}
// ============ MusicBrainz Adapter ============
/// Convert MusicBrainz release search result to unified DTO
pub fn musicbrainz_release_to_search_result(
release: &crate::musicbrainz::models::ReleaseResult,
) -> RemoteAlbumSearchResult {
// Extract artist from artist-credit
let artist = release
.artist_credit
.as_ref()
.map(|credits| {
credits
.iter()
.map(|c| {
format!(
"{}{}",
c.name.as_deref().unwrap_or(&c.artist.name),
c.joinphrase.as_deref().unwrap_or("")
)
})
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default();
// Extract year from date (YYYY or YYYY-MM-DD)
let year = release.date.as_ref().and_then(|d| {
d.split('-').next().and_then(|y| y.parse::<u16>().ok())
});
// Extract label and catalog number
let (label, catalog_number) = release
.label_info
.as_ref()
.and_then(|info| info.first())
.map(|li| {
(
li.label.as_ref().map(|l| l.name.clone()),
li.catalog_number.clone(),
)
})
.unwrap_or((None, None));
// Get track count - either from direct field or sum from media
let track_count = release.track_count.or_else(|| {
release.media.as_ref().map(|media| {
media.iter().filter_map(|m| m.track_count).sum()
})
});
// Get format from first medium
let format = release
.media
.as_ref()
.and_then(|m| m.first())
.and_then(|m| m.format.clone());
RemoteAlbumSearchResult {
provider: RemoteProvider::MusicBrainz,
provider_id: release.id.clone(),
title: release.title.clone(),
artist,
year,
track_count,
country: release.country.clone(),
label,
catalog_number,
confidence: release.score.map(|s| s.min(100) as u8),
format,
}
}
// ============ Discogs Adapter ============
/// Parse Discogs track position to (disc_number, track_number)
/// Handles formats: "1", "A1", "1-1", "CD1-1", "1.1"
pub fn parse_discogs_position(position: &str) -> (u8, u8) {
let position = position.trim();
// Handle empty position
if position.is_empty() {
return (1, 1);
}
// Try "X-Y" format (e.g., "1-5", "CD1-3")
if let Some(pos) = position.find('-') {
let disc_part = &position[..pos];
let track_part = &position[pos + 1..];
// Extract number from disc part (handle "CD1", "1", etc.)
let disc = disc_part
.chars()
.filter(|c| c.is_ascii_digit())
.collect::<String>()
.parse::<u8>()
.unwrap_or(1);
let track = track_part.parse::<u8>().unwrap_or(1);
return (disc, track);
}
// Try "X.Y" format
if let Some(pos) = position.find('.') {
let disc_part = &position[..pos];
let track_part = &position[pos + 1..];
let disc = disc_part.parse::<u8>().unwrap_or(1);
let track = track_part.parse::<u8>().unwrap_or(1);
return (disc, track);
}
// Handle vinyl sides (A, B, C, D -> disc 1, 1, 2, 2)
if position.starts_with(|c: char| c.is_ascii_alphabetic()) {
let side = position.chars().next().unwrap().to_ascii_uppercase();
let track_str: String = position.chars().skip(1).collect();
let track = track_str.parse::<u8>().unwrap_or(1);
let disc = match side {
'A' | 'B' => 1,
'C' | 'D' => 2,
'E' | 'F' => 3,
_ => 1,
};
return (disc, track);
}
// Simple number
let track = position.parse::<u8>().unwrap_or(1);
(1, track)
}
/// Parse Discogs duration string to milliseconds
/// Handles format: "M:SS" or "MM:SS" or "H:MM:SS"
pub fn parse_discogs_duration(duration: &str) -> Option<u32> {
let parts: Vec<&str> = duration.split(':').collect();
match parts.len() {
2 => {
// M:SS or MM:SS
let minutes: u32 = parts[0].parse().ok()?;
let seconds: u32 = parts[1].parse().ok()?;
Some((minutes * 60 + seconds) * 1000)
}
3 => {
// H:MM:SS
let hours: u32 = parts[0].parse().ok()?;
let minutes: u32 = parts[1].parse().ok()?;
let seconds: u32 = parts[2].parse().ok()?;
Some((hours * 3600 + minutes * 60 + seconds) * 1000)
}
_ => None,
}
}
/// Convert Discogs extended search result to unified DTO
pub fn discogs_extended_to_search_result(
result: &crate::discogs::DiscogsSearchResultExtended,
) -> RemoteAlbumSearchResult {
// Discogs title format is usually "Artist - Album"
let (artist, title) = if let Some(pos) = result.title.find(" - ") {
let (a, t) = result.title.split_at(pos);
(a.to_string(), t.trim_start_matches(" - ").to_string())
} else {
("Unknown Artist".to_string(), result.title.clone())
};
// Parse year from string
let year = result.year.as_ref().and_then(|y| y.parse::<u16>().ok());
// Get first label
let label = result.label.as_ref().and_then(|l| l.first().cloned());
// Get format as string
let format = result.format.as_ref().map(|f| f.join(", "));
RemoteAlbumSearchResult {
provider: RemoteProvider::Discogs,
provider_id: result.id.to_string(),
title,
artist,
year,
track_count: None,
country: result.country.clone(),
label,
catalog_number: result.catno.clone(),
confidence: None,
format,
}
}
/// Convert MusicBrainz full release to unified metadata DTO
pub fn musicbrainz_full_to_metadata(
release: &crate::musicbrainz::ReleaseFullResponse,
) -> RemoteAlbumMetadata {
// Extract artist from artist-credit
let artist = release
.artist_credit
.as_ref()
.map(|credits| {
credits
.iter()
.map(|c| {
format!(
"{}{}",
c.name.as_deref().unwrap_or(&c.artist.name),
c.joinphrase.as_deref().unwrap_or("")
)
})
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default();
// Extract year from date
let year = release.date.as_ref().and_then(|d| {
d.split('-').next().and_then(|y| y.parse::<u16>().ok())
});
// Extract genres from tags (sorted by count, take top 5)
let genres: Vec<String> = release.tags.as_ref().map(|tags| {
let mut sorted: Vec<_> = tags.iter().collect();
sorted.sort_by(|a, b| b.count.cmp(&a.count));
sorted.iter().take(5).map(|t| t.name.clone()).collect()
}).unwrap_or_default();
// Extract label and catalog number
let (label, catalog_number) = release
.label_info
.as_ref()
.and_then(|info| info.first())
.map(|li| {
(
li.label.as_ref().map(|l| l.name.clone()),
li.catalog_number.clone(),
)
})
.unwrap_or((None, None));
// Count discs
let disc_count = release.media.as_ref().map(|m| m.len() as u8).unwrap_or(1);
// Convert tracks
let tracks: Vec<RemoteTrackMetadata> = release.media.as_ref().map(|media| {
let mut all_tracks = Vec::new();
for medium in media {
if let Some(tracks) = &medium.tracks {
for track in tracks {
all_tracks.push(RemoteTrackMetadata {
disc_number: medium.position.unwrap_or(1),
track_number: track.position.unwrap_or(1),
title: track.title.clone().or_else(|| {
track.recording.as_ref().and_then(|r| r.title.clone())
}).unwrap_or_default(),
duration_ms: track.length.map(|l| l as u32).or_else(|| {
track.recording.as_ref().and_then(|r| r.length.map(|l| l as u32))
}),
});
}
}
}
all_tracks
}).unwrap_or_default();
RemoteAlbumMetadata {
provider: RemoteProvider::MusicBrainz,
provider_id: release.id.clone(),
title: release.title.clone(),
artist,
year,
genres,
label,
catalog_number,
country: release.country.clone(),
barcode: release.barcode.clone(),
tracks,
disc_count,
source_url: Some(format!("https://musicbrainz.org/release/{}", release.id)),
}
}
/// Convert Discogs full release to unified metadata DTO
pub fn discogs_full_to_metadata(
release: &crate::discogs::DiscogsReleaseMetadata,
) -> RemoteAlbumMetadata {
// Combine artists with join phrases
let artist = release
.artists
.as_ref()
.map(|artists| {
artists
.iter()
.map(|a| {
format!(
"{}{}",
a.name.clone(),
a.join.as_deref().unwrap_or("")
)
})
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default();
// Combine genres and styles
let genres: Vec<String> = {
let mut combined = Vec::new();
if let Some(g) = &release.genres {
combined.extend(g.clone());
}
if let Some(s) = &release.styles {
combined.extend(s.clone());
}
combined
};
// Get first label and catalog number
let (label, catalog_number) = release
.labels
.as_ref()
.and_then(|labels| labels.first())
.map(|l| (Some(l.name.clone()), l.catno.clone()))
.unwrap_or((None, None));
// Convert tracklist
let tracks: Vec<RemoteTrackMetadata> = release.tracklist.as_ref().map(|tracklist| {
tracklist
.iter()
.filter(|t| {
// Filter out headings (disc separators)
t.track_type.as_deref() != Some("heading")
})
.map(|t| {
let (disc_number, track_number) = parse_discogs_position(&t.position);
RemoteTrackMetadata {
disc_number,
track_number,
title: t.title.clone(),
duration_ms: t.duration.as_ref().and_then(|d| parse_discogs_duration(d)),
}
})
.collect()
}).unwrap_or_default();
// Count unique discs
let disc_count = tracks.iter().map(|t| t.disc_number).max().unwrap_or(1);
RemoteAlbumMetadata {
provider: RemoteProvider::Discogs,
provider_id: release.id.to_string(),
title: release.title.clone(),
artist,
year: release.year.map(|y| y as u16),
genres,
label,
catalog_number,
country: release.country.clone(),
barcode: None, // Discogs doesn't include barcode in release details
tracks,
disc_count,
source_url: release.uri.clone(),
}
}
// ============ Main Service Functions ============
impl RemoteMetadataState {
/// Search for albums using the specified provider
pub async fn search_albums(
&self,
provider: RemoteProvider,
query: &str,
artist: Option<&str>,
limit: usize,
) -> Result<Vec<RemoteAlbumSearchResult>, RemoteMetadataError> {
// Check cache first
if let Some(cached) = self.cache.get_search(provider, query, None, artist).await {
log::debug!("Cache hit for search: {:?} {}", provider, query);
return Ok(cached);
}
let results = match provider {
RemoteProvider::MusicBrainz => {
self.search_musicbrainz(query, artist.unwrap_or(""), limit).await?
}
RemoteProvider::Discogs => {
self.search_discogs(query, artist.unwrap_or(""), limit).await?
}
};
// Cache results
self.cache.set_search(provider, query, None, artist, results.clone()).await;
Ok(results)
}
/// Get full album metadata by provider ID
pub async fn get_album_metadata(
&self,
provider: RemoteProvider,
provider_id: &str,
) -> Result<RemoteAlbumMetadata, RemoteMetadataError> {
// Check cache first
if let Some(cached) = self.cache.get_metadata(provider, provider_id).await {
log::debug!("Cache hit for metadata: {:?} {}", provider, provider_id);
return Ok(cached);
}
let metadata = match provider {
RemoteProvider::MusicBrainz => {
self.get_musicbrainz_metadata(provider_id).await?
}
RemoteProvider::Discogs => {
self.get_discogs_metadata(provider_id).await?
}
};
// Cache results
self.cache.set_metadata(provider, provider_id, metadata.clone()).await;
Ok(metadata)
}
/// Search MusicBrainz for releases
async fn search_musicbrainz(
&self,
query: &str,
artist: &str,
limit: usize,
) -> Result<Vec<RemoteAlbumSearchResult>, RemoteMetadataError> {
let mb_state = self.musicbrainz.as_ref()
.ok_or(RemoteMetadataError::ProviderUnavailable("MusicBrainz not initialized".to_string()))?;
let response = mb_state.client
.search_releases_extended(query, artist, None, limit)
.await
.map_err(|e| RemoteMetadataError::NetworkError(e))?;
let results = response.releases
.iter()
.map(musicbrainz_release_to_search_result)
.collect();
Ok(results)
}
/// Search Discogs for releases
async fn search_discogs(
&self,
query: &str,
artist: &str,
limit: usize,
) -> Result<Vec<RemoteAlbumSearchResult>, RemoteMetadataError> {
let client = self.discogs.lock().await;
let results = client
.search_releases(artist, query, None, limit)
.await
.map_err(|e| RemoteMetadataError::NetworkError(e))?;
let converted = results
.iter()
.map(discogs_extended_to_search_result)
.collect();
Ok(converted)
}
/// Get full metadata from MusicBrainz
async fn get_musicbrainz_metadata(
&self,
release_id: &str,
) -> Result<RemoteAlbumMetadata, RemoteMetadataError> {
let mb_state = self.musicbrainz.as_ref()
.ok_or(RemoteMetadataError::ProviderUnavailable("MusicBrainz not initialized".to_string()))?;
let response = mb_state.client
.get_release_with_tracks(release_id)
.await
.map_err(|e| RemoteMetadataError::NetworkError(e))?;
Ok(musicbrainz_full_to_metadata(&response))
}
/// Get full metadata from Discogs
async fn get_discogs_metadata(
&self,
release_id: &str,
) -> Result<RemoteAlbumMetadata, RemoteMetadataError> {
let release_id: u64 = release_id.parse()
.map_err(|_| RemoteMetadataError::InvalidProviderId("Invalid Discogs release ID".to_string()))?;
let client = self.discogs.lock().await;
let response = client
.get_release_metadata(release_id)
.await
.map_err(|e| RemoteMetadataError::NetworkError(e))?;
Ok(discogs_full_to_metadata(&response))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_discogs_position() {
assert_eq!(parse_discogs_position("1"), (1, 1));
assert_eq!(parse_discogs_position("5"), (1, 5));
assert_eq!(parse_discogs_position("1-1"), (1, 1));
assert_eq!(parse_discogs_position("1-5"), (1, 5));
assert_eq!(parse_discogs_position("2-3"), (2, 3));
assert_eq!(parse_discogs_position("CD1-5"), (1, 5));
assert_eq!(parse_discogs_position("CD2-3"), (2, 3));
assert_eq!(parse_discogs_position("A1"), (1, 1));
assert_eq!(parse_discogs_position("B2"), (1, 2));
assert_eq!(parse_discogs_position("C1"), (2, 1));
assert_eq!(parse_discogs_position("D3"), (2, 3));
assert_eq!(parse_discogs_position("1.5"), (1, 5));
assert_eq!(parse_discogs_position("2.3"), (2, 3));
}
#[test]
fn test_parse_discogs_duration() {
assert_eq!(parse_discogs_duration("3:45"), Some(225000));
assert_eq!(parse_discogs_duration("0:30"), Some(30000));
assert_eq!(parse_discogs_duration("10:00"), Some(600000));
assert_eq!(parse_discogs_duration("1:00:00"), Some(3600000));
assert_eq!(parse_discogs_duration("invalid"), None);
}
}
@@ -0,0 +1,237 @@
//! Unified DTOs for remote metadata providers (MusicBrainz, Discogs)
//!
//! These structs provide a provider-neutral interface for the Tag Editor
//! to consume album metadata from different sources.
use serde::{Deserialize, Serialize};
/// Remote metadata provider
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RemoteProvider {
MusicBrainz,
Discogs,
}
impl std::fmt::Display for RemoteProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MusicBrainz => write!(f, "musicbrainz"),
Self::Discogs => write!(f, "discogs"),
}
}
}
impl std::str::FromStr for RemoteProvider {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"musicbrainz" | "mb" => Ok(Self::MusicBrainz),
"discogs" => Ok(Self::Discogs),
_ => Err(format!("Unknown provider: {}", s)),
}
}
}
/// Lightweight search result for displaying in results list
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteAlbumSearchResult {
/// Provider identifier
pub provider: RemoteProvider,
/// Provider-specific ID (MusicBrainz release ID or Discogs release ID)
pub provider_id: String,
/// Album title
pub title: String,
/// Album artist
pub artist: String,
/// Release year (extracted from date)
pub year: Option<u16>,
/// Number of tracks (if available from search)
pub track_count: Option<u16>,
/// Release country
pub country: Option<String>,
/// Record label
pub label: Option<String>,
/// Catalog number
pub catalog_number: Option<String>,
/// Match confidence (0-100, provider-specific)
pub confidence: Option<u8>,
/// Format info (e.g., "CD", "Vinyl", "Digital")
pub format: Option<String>,
}
/// Full album metadata with tracks (for applying to form)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteAlbumMetadata {
/// Provider identifier
pub provider: RemoteProvider,
/// Provider-specific ID
pub provider_id: String,
/// Album title
pub title: String,
/// Album artist
pub artist: String,
/// Release year
pub year: Option<u16>,
/// Genres/styles/tags
pub genres: Vec<String>,
/// Record label
pub label: Option<String>,
/// Catalog number
pub catalog_number: Option<String>,
/// Release country
pub country: Option<String>,
/// Barcode/UPC (if available)
pub barcode: Option<String>,
/// Track list organized by disc
pub tracks: Vec<RemoteTrackMetadata>,
/// Total disc count
pub disc_count: u8,
/// URL to view on provider website
pub source_url: Option<String>,
}
/// Single track metadata
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteTrackMetadata {
/// Disc number (1-based)
pub disc_number: u8,
/// Track number within disc (1-based)
pub track_number: u8,
/// Track title
pub title: String,
/// Duration in milliseconds (if available)
pub duration_ms: Option<u32>,
}
/// Search request parameters
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteSearchRequest {
/// Provider to search
pub provider: RemoteProvider,
/// Search query (usually "artist album")
pub query: String,
/// Optional catalog number for more precise matching
pub catalog_id: Option<String>,
/// Optional artist name for filtering
pub artist: Option<String>,
/// Maximum results to return (default: 10)
pub limit: Option<usize>,
}
impl RemoteSearchRequest {
pub fn limit(&self) -> usize {
self.limit.unwrap_or(10).min(25)
}
}
/// Search response wrapper
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteSearchResponse {
/// Provider that was searched
pub provider: RemoteProvider,
/// Search results
pub results: Vec<RemoteAlbumSearchResult>,
/// Total results available (may be more than returned)
pub total_count: Option<usize>,
/// Whether rate limit was hit
pub rate_limited: bool,
}
impl RemoteSearchResponse {
pub fn empty(provider: RemoteProvider) -> Self {
Self {
provider,
results: Vec::new(),
total_count: Some(0),
rate_limited: false,
}
}
pub fn rate_limited(provider: RemoteProvider) -> Self {
Self {
provider,
results: Vec::new(),
total_count: None,
rate_limited: true,
}
}
}
/// Error types for remote metadata operations
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "message")]
pub enum RemoteMetadataError {
/// Network or connection error
NetworkError(String),
/// Rate limit exceeded
RateLimited(String),
/// Invalid response from provider
InvalidResponse(String),
/// No results found
NoResults,
/// Provider not available
ProviderUnavailable(String),
/// Invalid provider ID
InvalidProviderId(String),
}
impl std::fmt::Display for RemoteMetadataError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NetworkError(msg) => write!(f, "Network error: {}", msg),
Self::RateLimited(msg) => write!(f, "Rate limited: {}", msg),
Self::InvalidResponse(msg) => write!(f, "Invalid response: {}", msg),
Self::NoResults => write!(f, "No results found"),
Self::ProviderUnavailable(msg) => write!(f, "Provider unavailable: {}", msg),
Self::InvalidProviderId(msg) => write!(f, "Invalid provider ID: {}", msg),
}
}
}
impl From<RemoteMetadataError> for String {
fn from(err: RemoteMetadataError) -> Self {
err.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_provider_parsing() {
assert_eq!(
"musicbrainz".parse::<RemoteProvider>().unwrap(),
RemoteProvider::MusicBrainz
);
assert_eq!(
"discogs".parse::<RemoteProvider>().unwrap(),
RemoteProvider::Discogs
);
assert_eq!(
"MB".parse::<RemoteProvider>().unwrap(),
RemoteProvider::MusicBrainz
);
assert!("unknown".parse::<RemoteProvider>().is_err());
}
#[test]
fn test_search_request_limit() {
let req = RemoteSearchRequest {
provider: RemoteProvider::MusicBrainz,
query: "test".to_string(),
catalog_id: None,
artist: None,
limit: None,
};
assert_eq!(req.limit(), 10);
let req_custom = RemoteSearchRequest {
limit: Some(50),
..req.clone()
};
assert_eq!(req_custom.limit(), 25); // Capped at 25
}
}
+153
View File
@@ -0,0 +1,153 @@
//! LocalLibrary album tag sidecar support.
//!
//! Sidecar files live next to album folders (default `.qbz.json`) and store
//! album-level + per-track metadata overrides.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::library::{LibraryError, LocalTrack};
const SIDECAR_FILE_NAME: &str = ".qbz.json";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct AlbumMetadataOverride {
pub album_title: Option<String>,
pub album_artist: Option<String>,
pub year: Option<u32>,
pub genre: Option<String>,
pub catalog_number: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TrackMetadataOverride {
pub file_path: String,
pub cue_start_secs: Option<f64>,
pub title: Option<String>,
pub disc_number: Option<u32>,
pub track_number: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AlbumTagSidecar {
pub version: u32,
pub updated_at: i64,
pub album: AlbumMetadataOverride,
pub tracks: Vec<TrackMetadataOverride>,
}
impl AlbumTagSidecar {
pub fn new(album: AlbumMetadataOverride, tracks: Vec<TrackMetadataOverride>) -> Self {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
Self {
version: 1,
updated_at: now,
album,
tracks,
}
}
}
pub fn sidecar_path(album_dir: &Path) -> PathBuf {
album_dir.join(SIDECAR_FILE_NAME)
}
pub fn read_album_sidecar(album_dir: &Path) -> Result<Option<AlbumTagSidecar>, LibraryError> {
let path = sidecar_path(album_dir);
if !path.exists() {
return Ok(None);
}
let bytes = fs::read(&path).map_err(LibraryError::Io)?;
let sidecar: AlbumTagSidecar =
serde_json::from_slice(&bytes).map_err(|e| LibraryError::Metadata(e.to_string()))?;
Ok(Some(sidecar))
}
pub fn write_album_sidecar(album_dir: &Path, sidecar: &AlbumTagSidecar) -> Result<(), LibraryError> {
fs::create_dir_all(album_dir).map_err(LibraryError::Io)?;
let target = sidecar_path(album_dir);
let tmp = album_dir.join(format!("{}.tmp", SIDECAR_FILE_NAME));
let content =
serde_json::to_vec_pretty(sidecar).map_err(|e| LibraryError::Metadata(e.to_string()))?;
fs::write(&tmp, content).map_err(LibraryError::Io)?;
fs::rename(&tmp, &target).map_err(LibraryError::Io)?;
Ok(())
}
pub fn delete_album_sidecar(album_dir: &Path) -> Result<(), LibraryError> {
let path = sidecar_path(album_dir);
if !path.exists() {
return Ok(());
}
fs::remove_file(&path).map_err(LibraryError::Io)?;
Ok(())
}
pub fn apply_sidecar_to_track(track: &mut LocalTrack, sidecar: &AlbumTagSidecar) {
if let Some(title) = sidecar.album.album_title.as_ref().and_then(|s| normalize(s)) {
track.album = title.clone();
track.album_group_title = title.clone();
}
if let Some(album_artist) = sidecar.album.album_artist.as_ref().and_then(|s| normalize(s)) {
track.album_artist = Some(album_artist.clone());
}
if let Some(year) = sidecar.album.year {
track.year = Some(year);
}
if let Some(genre) = sidecar.album.genre.as_ref().and_then(|s| normalize(s)) {
track.genre = Some(genre.clone());
}
if let Some(cat) = sidecar
.album
.catalog_number
.as_ref()
.and_then(|s| normalize(s))
{
track.catalog_number = Some(cat.clone());
}
if let Some(entry) = sidecar.tracks.iter().find(|t| {
t.file_path == track.file_path
&& match (t.cue_start_secs, track.cue_start_secs) {
(Some(a), Some(b)) => (a - b).abs() < 0.001,
(None, None) => true,
_ => false,
}
}) {
if let Some(title) = entry.title.as_ref().and_then(|s| normalize(s)) {
track.title = title.clone();
}
if let Some(disc) = entry.disc_number {
track.disc_number = Some(disc);
}
if let Some(no) = entry.track_number {
track.track_number = Some(no);
}
}
}
fn normalize(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
+80 -8
View File
@@ -314,6 +314,21 @@ impl MusicBrainzClient {
&self,
title: &str,
artist: &str,
) -> Result<ReleaseSearchResponse, String> {
self.search_releases_extended(title, artist, None, 5).await
}
/// Search releases with extended options
/// - `title`: Album title to search
/// - `artist`: Artist name to search
/// - `catalog_number`: Optional catalog number for more precise matching
/// - `limit`: Maximum results to return (1-25)
pub async fn search_releases_extended(
&self,
title: &str,
artist: &str,
catalog_number: Option<&str>,
limit: usize,
) -> Result<ReleaseSearchResponse, String> {
if !self.is_enabled().await {
return Err("MusicBrainz integration is disabled".to_string());
@@ -322,18 +337,37 @@ impl MusicBrainzClient {
self.rate_limiter.wait().await;
let base_url = self.base_url().await;
let query = format!(
"release:\"{}\" AND artist:\"{}\"",
Self::escape_query(title),
Self::escape_query(artist)
);
// Build query - if catalog number provided, prioritize it
let query = if let Some(catno) = catalog_number.filter(|s| !s.trim().is_empty()) {
format!(
"catno:\"{}\" AND artist:\"{}\"",
Self::escape_query(catno),
Self::escape_query(artist)
)
} else {
format!(
"release:\"{}\" AND artist:\"{}\"",
Self::escape_query(title),
Self::escape_query(artist)
)
};
let limit = limit.min(25).max(1);
let url = format!(
"{}/release?query={}&fmt=json&limit=5",
"{}/release?query={}&fmt=json&limit={}",
base_url,
urlencoding::encode(&query)
urlencoding::encode(&query),
limit
);
log::debug!("MusicBrainz release search: {} - {}", artist, title);
log::debug!(
"MusicBrainz release search: {} - {} (catalog: {:?}, limit: {})",
artist,
title,
catalog_number,
limit
);
let response = self
.client
@@ -354,6 +388,44 @@ impl MusicBrainzClient {
.map_err(|e| format!("Failed to parse MusicBrainz response: {}", e))
}
/// Get full release details including tracks
/// Fetches media, recordings, artist credits, labels, and tags
pub async fn get_release_with_tracks(&self, release_id: &str) -> Result<ReleaseFullResponse, String> {
if !self.is_enabled().await {
return Err("MusicBrainz integration is disabled".to_string());
}
self.rate_limiter.wait().await;
let base_url = self.base_url().await;
// inc=recordings gets track info, artist-credits for artist info,
// labels for label/catalog, tags for genres
let url = format!(
"{}/release/{}?inc=recordings+artist-credits+labels+tags&fmt=json",
base_url, release_id
);
log::debug!("MusicBrainz release lookup with tracks: {}", release_id);
let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| format!("MusicBrainz request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(format!("MusicBrainz API error {}: {}", status, text));
}
response
.json::<ReleaseFullResponse>()
.await
.map_err(|e| format!("Failed to parse MusicBrainz response: {}", e))
}
/// Escape special characters in Lucene queries
fn escape_query(s: &str) -> String {
s.replace('\\', "\\\\")
+3 -3
View File
@@ -22,9 +22,9 @@ pub mod smart_playlists;
pub use cache::{CacheStats, MusicBrainzCache, MusicBrainzCacheState};
pub use client::{MusicBrainzClient, MusicBrainzConfig};
pub use models::{
AlbumAppearance, ArtistRelationships, ArtistType, MatchConfidence, MusicianAppearances,
MusicianConfidence, Period, RelatedArtist, ResolvedArtist, ResolvedMusician, ResolvedRelease,
ResolvedTrack,
AlbumAppearance, ArtistRelationships, ArtistType, MatchConfidence, Medium, MediumTrack,
MusicianAppearances, MusicianConfidence, Period, RelatedArtist, ReleaseFullResponse,
ReleaseSearchResponse, ResolvedArtist, ResolvedMusician, ResolvedRelease, ResolvedTrack, Tag,
};
use std::sync::Arc;
+85
View File
@@ -222,6 +222,19 @@ pub struct ReleaseResult {
pub release_group: Option<ReleaseGroupRef>,
#[serde(rename = "artist-credit")]
pub artist_credit: Option<Vec<ArtistCredit>>,
/// Media info (contains track counts per disc)
pub media: Option<Vec<ReleaseSearchMedium>>,
/// Total track count across all media
#[serde(rename = "track-count")]
pub track_count: Option<u16>,
}
/// Simplified medium info for search results
#[derive(Debug, Deserialize)]
pub struct ReleaseSearchMedium {
pub format: Option<String>,
#[serde(rename = "track-count")]
pub track_count: Option<u16>,
}
/// Label information
@@ -239,6 +252,78 @@ pub struct LabelRef {
pub name: String,
}
// ============ Full Release Response (with tracks) ============
/// Full release response from lookup endpoint
/// Includes media, tracks, and tags when requested via inc parameter
#[derive(Debug, Deserialize)]
pub struct ReleaseFullResponse {
pub id: String,
pub title: String,
pub status: Option<String>,
pub date: Option<String>,
pub country: Option<String>,
pub barcode: Option<String>,
#[serde(rename = "artist-credit")]
pub artist_credit: Option<Vec<ArtistCredit>>,
#[serde(rename = "label-info")]
pub label_info: Option<Vec<LabelInfo>>,
#[serde(rename = "release-group")]
pub release_group: Option<ReleaseGroupRef>,
/// Media (CDs, vinyl sides, etc.) containing tracks
pub media: Option<Vec<Medium>>,
/// Community tags (genres)
pub tags: Option<Vec<Tag>>,
}
/// A medium (disc) containing tracks
#[derive(Debug, Deserialize)]
pub struct Medium {
/// Position of this medium in the release (1-based)
pub position: Option<u8>,
/// Format (CD, Vinyl, Digital Media, etc.)
pub format: Option<String>,
/// Track count on this medium
#[serde(rename = "track-count")]
pub track_count: Option<u16>,
/// Tracks on this medium
pub tracks: Option<Vec<MediumTrack>>,
}
/// A track on a medium
#[derive(Debug, Deserialize)]
pub struct MediumTrack {
/// Track position on the medium (1-based)
pub position: Option<u8>,
/// Track number as string (may differ from position for vinyl etc.)
pub number: Option<String>,
/// Track title (may differ from recording title)
pub title: Option<String>,
/// Track length in milliseconds
pub length: Option<i64>,
/// The recording this track is linked to
pub recording: Option<TrackRecording>,
}
/// Recording reference within a track
#[derive(Debug, Deserialize)]
pub struct TrackRecording {
pub id: String,
pub title: Option<String>,
pub length: Option<i64>,
#[serde(rename = "artist-credit")]
pub artist_credit: Option<Vec<ArtistCredit>>,
}
/// Community tag (used for genres)
#[derive(Debug, Deserialize)]
pub struct Tag {
/// Tag name (e.g., "rock", "electronic")
pub name: String,
/// Number of times this tag was applied
pub count: Option<i32>,
}
// ============ Resolved Types (Output) ============
/// Resolved track with MusicBrainz data
-1
View File
@@ -36,7 +36,6 @@
"$HOME/.cache/qbz/**",
"$HOME/.config/qbz/**",
"$HOME/.local/share/qbz/**",
"$HOME/**",
"/mnt/**",
"/media/**",
"/run/media/**"
+114
View File
@@ -16,6 +16,16 @@
--accent-hover: #5a9bf4;
--accent-active: #3275e4;
--danger: #ef4444;
--danger-bg: rgba(239, 68, 68, 0.1);
--danger-border: rgba(239, 68, 68, 0.3);
--danger-hover: rgba(239, 68, 68, 0.2);
--warning: #fbbf24;
--warning-bg: rgba(251, 191, 36, 0.1);
--warning-border: rgba(251, 191, 36, 0.3);
--warning-hover: rgba(251, 191, 36, 0.2);
--border-subtle: #2a2a2a;
--border-strong: #3a3a3a;
@@ -75,6 +85,16 @@
--border-subtle: #e0e0e0;
--border-strong: #cccccc;
--danger: #dc2626;
--danger-bg: rgba(220, 38, 38, 0.1);
--danger-border: rgba(220, 38, 38, 0.3);
--danger-hover: rgba(220, 38, 38, 0.15);
--warning: #d97706;
--warning-bg: rgba(217, 119, 6, 0.1);
--warning-border: rgba(217, 119, 6, 0.3);
--warning-hover: rgba(217, 119, 6, 0.15);
/* Alpha overrides for light theme - use black-based */
--alpha-4: rgba(0, 0, 0, 0.04);
--alpha-5: rgba(0, 0, 0, 0.05);
@@ -448,3 +468,97 @@ select {
box-shadow: inset 0 0 0 1px var(--accent-primary);
color: var(--accent-primary);
}
/* ============ Standard Buttons ============ */
/* Reusable button styles for modals, forms, and actions */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
border: 1px solid transparent;
white-space: nowrap;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent-primary);
color: var(--bg-primary);
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-secondary {
background: var(--bg-secondary);
border-color: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-tertiary);
}
.btn-danger {
background: var(--danger-bg);
border: 1px solid var(--danger-border);
color: var(--danger);
}
.btn-danger:hover:not(:disabled) {
background: var(--danger-hover);
}
.btn-ghost {
background: transparent;
color: var(--text-muted);
}
.btn-ghost:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
/* Button sizes */
.btn-sm {
padding: 6px 14px;
font-size: 13px;
border-radius: 6px;
}
.btn-lg {
padding: 12px 28px;
font-size: 15px;
border-radius: 10px;
}
/* ============ Utility Classes ============ */
/* Icon inline with text */
.icon-inline {
display: inline;
vertical-align: middle;
margin-right: 4px;
}
/* Warning badge (complements .btn-danger) */
.badge-warning {
background: rgba(251, 191, 36, 0.1);
color: #fbbf24;
border: 1px solid rgba(251, 191, 36, 0.3);
padding: 8px 12px;
border-radius: 8px;
font-size: 13px;
}
+20 -7
View File
@@ -14,7 +14,7 @@
artwork: string;
title: string;
artist: string;
genre: string;
genre?: string;
releaseDate?: string;
quality?: string;
size?: 'standard' | 'large';
@@ -29,6 +29,7 @@
onDownload?: () => void;
showFavorite?: boolean;
favoriteEnabled?: boolean;
showGenre?: boolean;
isAlbumFullyDownloaded?: boolean;
onOpenContainingFolder?: () => void;
onReDownloadAlbum?: () => void;
@@ -55,6 +56,7 @@
onDownload,
showFavorite,
favoriteEnabled,
showGenre = true,
isAlbumFullyDownloaded = false,
onOpenContainingFolder,
onReDownloadAlbum,
@@ -199,12 +201,14 @@
<!-- Action Overlay -->
{#if hasOverlay}
<div class="action-overlay" class:menu-open={menuOpen}>
<div class="overlay-info">
<span class="overlay-genre">{genre}</span>
{#if formattedDate}
<span class="overlay-date">{formattedDate}</span>
{/if}
</div>
{#if showGenre && (genre || formattedDate)}
<div class="overlay-info">
{#if genre}<span class="overlay-genre">{genre}</span>{/if}
{#if formattedDate}
<span class="overlay-date">{formattedDate}</span>
{/if}
</div>
{/if}
<div class="action-buttons">
{#if showFavoriteButton}
<button
@@ -219,6 +223,9 @@
>
<Heart size={18} fill={isFavorite ? 'white' : 'none'} color="white" />
</button>
{:else}
<!-- Spacer to keep Play button centered -->
<div class="overlay-btn--spacer"></div>
{/if}
{#if onPlay}
<button class="overlay-btn" type="button" onclick={handlePlay} title="Play">
@@ -431,6 +438,12 @@
height: 30px;
}
.overlay-btn--spacer {
width: 30px;
height: 30px;
visibility: hidden;
}
.overlay-info {
align-self: flex-start;
width: 100%;
+76 -11
View File
@@ -2,6 +2,7 @@
import { invoke } from '@tauri-apps/api/core';
import { onMount } from 'svelte';
import { getDevicePrettyName, isExternalDevice } from '$lib/utils/audioDeviceNames';
import { isCasting, getConnectedDevice, getConnectedProtocol, subscribe as subscribeCast } from '$lib/stores/castStore';
interface AudioSettings {
output_device: string | null;
@@ -44,6 +45,11 @@
let pipewireSinks = $state<PipewireSink[]>([]);
let hardwareStatus = $state<HardwareAudioStatus | null>(null);
let isHovering = $state(false);
// Cast state
let castConnected = $state(false);
let castDeviceName = $state<string | null>(null);
let castProtocol = $state<string | null>(null);
// Ticker animation for long device names
let deviceNameRef: HTMLDivElement | null = $state(null);
@@ -133,9 +139,9 @@
return 'DAC Passthrough activo';
});
// Whether to show badges at all (only if at least one setting is enabled or device is external)
// Whether to show badges at all (only if at least one setting is enabled or device is external or casting)
const shouldShowBadges = $derived(
settings?.dac_passthrough || settings?.exclusive_mode || isExternal || isAlsaDirect
castConnected || settings?.dac_passthrough || settings?.exclusive_mode || isExternal || isAlsaDirect
);
async function loadStatus() {
@@ -174,12 +180,19 @@
onMount(() => {
loadStatus();
// Subscribe to cast state
const unsubscribeCast = subscribeCast(() => {
castConnected = isCasting();
castDeviceName = getConnectedDevice()?.name ?? null;
castProtocol = getConnectedProtocol();
});
// Lightweight polling: ONLY update hardware status (no device enumeration)
// This reads /proc/asound which is very cheap, no CPAL/ALSA enumeration
const pollInterval = setInterval(async () => {
// Only poll if using bit-perfect modes
if (settings?.dac_passthrough || settings?.backend_type === 'Alsa') {
// Only poll if using bit-perfect modes AND not casting
if (!castConnected && (settings?.dac_passthrough || settings?.backend_type === 'Alsa')) {
try {
hardwareStatus = await invoke<HardwareAudioStatus>('get_hardware_audio_status').catch(() => null);
} catch (err) {
@@ -189,7 +202,10 @@
}, 1000);
// Cleanup on unmount
return () => clearInterval(pollInterval);
return () => {
clearInterval(pollInterval);
unsubscribeCast();
};
});
</script>
@@ -200,8 +216,22 @@
onmouseenter={() => isHovering = true}
onmouseleave={() => isHovering = false}
>
<!-- DAC Badge or HW Badge (mutually exclusive) -->
{#if isAlsaDirect}
<!-- Casting Badge (shown when casting to external device) -->
{#if castConnected}
<div class="badge cast-badge active">
<span class="badge-label">
{#if castProtocol === 'dlna'}
DLNA
{:else if castProtocol === 'chromecast'}
CAST
{:else if castProtocol === 'airplay'}
AIR
{:else}
CAST
{/if}
</span>
</div>
{:else if isAlsaDirect}
<!-- Hardware Direct Badge (ALSA Direct mode) -->
<div class="badge hw-badge active">
<span class="badge-label">HW</span>
@@ -218,20 +248,33 @@
</div>
{/if}
<!-- Exclusive Mode Badge -->
<!-- Exclusive Mode Badge (only show when not casting) -->
{#if !castConnected}
<div
class="badge"
class:active={exclusiveModeActive}
>
<span class="badge-label">EXC</span>
</div>
{/if}
<!-- Unified Tooltip on hover -->
{#if isHovering && showTooltips}
<div class="device-tooltip">
<!-- Output Device -->
<div class="tooltip-section">
<div class="tooltip-label">Output Device</div>
<div class="tooltip-label">{castConnected ? 'Casting to' : 'Output Device'}</div>
{#if castConnected}
<div class="tooltip-device cast-device">
{#if castProtocol === 'dlna'}
<img src="/dlna.svg" alt="DLNA" class="cast-icon-svg" />
{:else}
<img src="/cast_audio.svg" alt="Cast" class="cast-icon-svg" />
{/if}
<span class="device-name-text">{castDeviceName || 'Unknown Device'}</span>
</div>
<div class="tooltip-raw">{castProtocol?.toUpperCase()} Streaming</div>
{:else}
<div
class="tooltip-device"
class:scrollable={deviceNameOverflow > 0}
@@ -251,10 +294,11 @@
<span class="volume-value">{currentVolume}%</span>
</div>
{/if}
{/if}
</div>
<!-- Audio Settings -->
{#if isAlsaDirect || settings?.dac_passthrough || settings?.exclusive_mode}
<!-- Audio Settings (only show when not casting) -->
{#if !castConnected && (isAlsaDirect || settings?.dac_passthrough || settings?.exclusive_mode)}
<div class="tooltip-section">
<div class="tooltip-label">Audio Settings</div>
{#if isAlsaDirect}
@@ -355,6 +399,13 @@
border-color: rgba(59, 130, 246, 0.4);
}
/* Cast Badge - Purple for network streaming */
.badge.cast-badge {
background: rgba(168, 85, 247, 0.2);
color: #a855f7;
border-color: rgba(168, 85, 247, 0.4);
}
.badge-label {
line-height: 1;
}
@@ -433,6 +484,20 @@
text-overflow: ellipsis;
}
/* Cast device styling in tooltip */
.tooltip-device.cast-device {
display: flex;
align-items: center;
gap: 6px;
color: #a855f7;
}
.cast-icon-svg {
width: 14px;
height: 14px;
filter: invert(55%) sepia(80%) saturate(2000%) hue-rotate(240deg) brightness(100%);
}
.tooltip-volume {
display: flex;
align-items: center;
+10 -10
View File
@@ -407,7 +407,7 @@
z-index: 100;
display: flex;
flex-direction: column;
background-color: #0a0a0a;
background-color: var(--bg-primary);
animation: fadeIn 200ms ease-out;
overflow: hidden;
}
@@ -463,7 +463,7 @@
}
.close-btn:hover {
color: white;
color: var(--text-primary);
background: rgba(0, 0, 0, 0.5);
}
@@ -532,7 +532,7 @@
.title {
font-size: 22px;
font-weight: 600;
color: white;
color: var(--text-primary);
margin: 0 0 6px;
overflow: hidden;
text-overflow: ellipsis;
@@ -603,7 +603,7 @@
.progress-fill {
height: 100%;
background-color: white;
background-color: var(--text-primary);
border-radius: 2px;
transition: width 100ms linear;
}
@@ -614,7 +614,7 @@
width: 12px;
height: 12px;
border-radius: 50%;
background-color: white;
background-color: var(--text-primary);
transform: translate(-50%, -50%);
opacity: 0;
transition: opacity 150ms ease;
@@ -645,12 +645,12 @@
}
.control-btn:hover {
color: white;
color: var(--text-primary);
background: var(--alpha-10);
}
.control-btn.primary {
color: white;
color: var(--text-primary);
}
.control-btn.active {
@@ -702,7 +702,7 @@
.volume-fill {
height: 100%;
background-color: white;
background-color: var(--text-primary);
border-radius: 2px;
}
@@ -712,7 +712,7 @@
width: 12px;
height: 12px;
border-radius: 50%;
background-color: white;
background-color: var(--text-primary);
transform: translate(-50%, -50%);
opacity: 0;
transition: opacity 150ms ease;
@@ -765,7 +765,7 @@
}
.action-btn:hover {
color: white;
color: var(--text-primary);
background: var(--alpha-10);
}
@@ -550,39 +550,6 @@
padding-top: 24px;
}
.btn {
padding: 8px 20px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
border: none;
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-tertiary);
}
.btn-primary {
background: var(--accent-primary);
color: var(--bg-primary);
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-custom-gradient {
width: 100%;
padding: 8px 16px;
+9 -9
View File
@@ -299,7 +299,7 @@
position: fixed;
inset: 0;
z-index: 110;
background-color: #000;
background-color: var(--bg-primary);
cursor: default;
animation: fadeIn 300ms ease-out;
overflow: hidden;
@@ -362,7 +362,7 @@
}
.close-btn:hover {
color: white;
color: var(--text-primary);
background: rgba(0, 0, 0, 0.5);
}
@@ -534,7 +534,7 @@
.track-title {
font-size: 14px;
font-weight: 600;
color: white;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -595,7 +595,7 @@
}
.control-btn:not(:disabled):hover {
color: white;
color: var(--text-primary);
background: var(--alpha-10);
}
@@ -604,7 +604,7 @@
height: 52px;
background: var(--alpha-15);
backdrop-filter: blur(4px);
color: white;
color: var(--text-primary);
}
.play-btn:hover {
@@ -647,7 +647,7 @@
.progress-fill {
height: 100%;
background: white;
background: var(--text-primary);
border-radius: 2px;
transition: width 100ms linear;
}
@@ -658,7 +658,7 @@
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
background: var(--text-primary);
transform: translate(-50%, -50%);
opacity: 0;
transition: opacity 150ms ease;
@@ -703,7 +703,7 @@
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff;
background: var(--text-primary);
opacity: 0;
transition: opacity 150ms ease;
}
@@ -719,7 +719,7 @@
padding: 4px 8px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
color: var(--text-primary);
font-size: 12px;
opacity: 0;
transform: translateY(4px);
+2 -41
View File
@@ -429,47 +429,8 @@
gap: 12px;
}
.btn {
padding: 8px 20px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
border: 1px solid transparent;
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-tertiary);
}
.btn-primary {
background: var(--accent-primary);
color: var(--bg-primary);
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-danger {
/* Layout-specific: push danger button to the left */
.modal-actions :global(.btn-danger) {
margin-right: auto;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
}
.btn-danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.2);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -689,42 +689,6 @@
cursor: not-allowed;
}
.btn-secondary,
.btn-primary {
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
}
.btn-secondary {
background: transparent;
border: 1px solid var(--text-muted);
color: var(--text-primary);
}
.btn-secondary:hover:not(:disabled) {
border-color: var(--text-primary);
}
.btn-primary {
background: var(--accent-primary);
border: none;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-secondary:disabled,
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
File diff suppressed because it is too large. Load diff
+2 -31
View File
@@ -327,37 +327,8 @@
width: 100%;
}
.btn {
/* Layout-specific: equal width buttons */
.modal-footer :global(.btn) {
flex: 1;
padding: 10px 20px;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
border: none;
font-size: 14px;
}
.btn-primary {
background: var(--accent-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-primary-hover);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-tertiary);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
+13 -3
View File
@@ -33,12 +33,21 @@
onClose();
}
}
function portal(node: HTMLElement) {
document.body.appendChild(node);
return {
destroy() {
node.remove();
}
};
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<div class="modal-overlay" onclick={handleBackdropClick} role="dialog" aria-modal="true">
<div class="modal-overlay" use:portal onclick={handleBackdropClick} role="dialog" aria-modal="true">
<div class="modal" style="max-width: {maxWidth}">
{#if title || showCloseButton}
<div class="modal-header">
@@ -77,8 +86,9 @@
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
padding-left: 140px; /* Half of sidebar width (280px) for visual center */
z-index: 10000;
z-index: 200000;
animation: fade-in 150ms ease;
}
@@ -92,7 +102,7 @@
border: 1px solid var(--bg-tertiary);
border-radius: 12px;
width: 100%;
max-height: 80vh;
max-height: calc(100dvh - 40px);
display: flex;
flex-direction: column;
animation: slide-up 200ms ease;
@@ -523,35 +523,6 @@
border-top: 1px solid var(--bg-tertiary);
}
.btn-secondary,
.btn-primary {
padding: 10px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: transform 150ms ease, background 150ms ease, opacity 150ms ease;
}
.btn-secondary {
background: transparent;
border: 1px solid var(--bg-tertiary);
color: var(--text-secondary);
}
.btn-primary {
background: var(--accent-primary);
border: none;
color: var(--text-on-accent);
}
.btn-primary:disabled,
.btn-secondary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.spinner {
width: 14px;
height: 14px;
+1 -37
View File
@@ -504,7 +504,7 @@
{#if folders.length > 0}
<div class="form-group">
<label for="folder-select">
<Folder size={14} style="display: inline; vertical-align: middle; margin-right: 4px;" />
<Folder size={14} class="icon-inline" />
Folder
</label>
<select
@@ -777,42 +777,6 @@
margin-left: auto;
}
.btn-secondary,
.btn-primary {
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
}
.btn-secondary {
background: transparent;
border: 1px solid var(--text-muted);
color: var(--text-primary);
}
.btn-secondary:hover:not(:disabled) {
border-color: var(--text-primary);
}
.btn-primary {
background: var(--accent-primary);
border: none;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-secondary:disabled,
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hidden-label {
display: flex;
align-items: center;
-35
View File
@@ -1596,41 +1596,6 @@
justify-content: flex-end;
}
.btn-secondary,
.btn-primary {
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background-color 150ms ease, opacity 150ms ease;
}
.btn-secondary {
background: var(--bg-tertiary);
border: 1px solid var(--alpha-10);
color: var(--text-primary);
}
.btn-secondary:hover {
background: var(--bg-hover);
}
.btn-primary {
background: var(--accent-primary);
border: none;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-secondary);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toggle-btn {
position: absolute;
right: -10px;
@@ -312,11 +312,10 @@
artwork={getArtworkUrl(album.artwork_path)}
title={album.title}
artist={album.artist}
genre={"Unknown genre"}
quality={getQualityBadge(album)}
size="large"
showFavorite={true}
favoriteEnabled={false}
showFavorite={false}
showGenre={false}
onPlay={() => onAlbumPlay(album)}
onPlayNext={() => onAlbumQueueNext(album)}
onPlayLater={() => onAlbumQueueLater(album)}
@@ -654,7 +654,7 @@
.album-stats {
font-size: 14px;
color: #666666;
color: var(--text-muted);
margin-bottom: 24px;
}
@@ -694,7 +694,7 @@
gap: 16px;
font-size: 12px;
text-transform: uppercase;
color: #666666;
color: var(--text-muted);
font-weight: 400;
box-sizing: border-box;
}
+354 -171
View File
@@ -6,11 +6,13 @@
import {
HardDrive, Music, Disc3, Mic2, FolderPlus, Trash2, RefreshCw,
Settings, ArrowLeft, X, Play, AlertCircle, ImageDown, Upload, Search, LayoutGrid, List, Edit3,
Network, Power, PowerOff, ChevronLeft, ChevronRight, Shuffle, SlidersHorizontal, ArrowUpDown, ChevronDown
Network, Power, PowerOff, ChevronLeft, ChevronRight, Shuffle, SlidersHorizontal, ArrowUpDown, ChevronDown, Check
} from 'lucide-svelte';
import FolderSettingsModal from '../FolderSettingsModal.svelte';
import LocalLibraryTagEditorModal from '../LocalLibraryTagEditorModal.svelte';
import { t } from '$lib/i18n';
import { downloadSettingsVersion } from '$lib/stores/downloadSettingsStore';
import { showToast } from '$lib/stores/toastStore';
import AlbumCard from '../AlbumCard.svelte';
import VirtualizedAlbumList from '../VirtualizedAlbumList.svelte';
import VirtualizedArtistGrid from '../VirtualizedArtistGrid.svelte';
@@ -55,6 +57,7 @@
disc_number?: number;
year?: number;
genre?: string;
catalog_number?: string;
duration_secs: number;
format: string;
bit_depth?: number;
@@ -630,13 +633,16 @@
// Album edit modal state
let showAlbumEditModal = $state(false);
let editingAlbumTitle = $state('');
let showTagEditorModal = $state(false);
let refreshingAlbumMetadata = $state(false);
let albumMetadataRefreshed = $state(false);
let editingAlbumHidden = $state(false);
let discogsImageOptions = $state<DiscogsImageOption[]>([]);
let selectedDiscogsImage = $state<string | null>(null);
let fetchingDiscogsImages = $state(false);
let discogsFetchSuccessful = $state(false);
let discogsImagePage = $state(0);
const IMAGES_PER_PAGE = 4;
const IMAGES_PER_PAGE = 3;
// Folder selection state (by folder ID)
let selectedFolders = $state<Set<number>>(new Set());
@@ -1468,13 +1474,55 @@
function openAlbumEditModal() {
if (!selectedAlbum) return;
editingAlbumTitle = selectedAlbum.title;
editingAlbumHidden = false;
albumMetadataRefreshed = false;
discogsImageOptions = [];
selectedDiscogsImage = null;
discogsFetchSuccessful = false;
showAlbumEditModal = true;
}
function openTagEditorFromAlbumSettings() {
if (!selectedAlbum) return;
showAlbumEditModal = false;
showTagEditorModal = true;
}
async function handleTagEditorSaved() {
if (!selectedAlbum) return;
await loadLibraryData();
await loadAlbumById(selectedAlbum.id);
}
async function handleRefreshAlbumMetadataFromFiles() {
if (!selectedAlbum || refreshingAlbumMetadata) return;
const confirmed = await ask(
'This will re-read embedded metadata from the audio files and discard QBZ sidecar overrides for this album.',
{
title: 'Refresh metadata from files?',
kind: 'warning',
okLabel: 'Refresh',
cancelLabel: 'Cancel'
}
);
if (!confirmed) return;
try {
refreshingAlbumMetadata = true;
albumMetadataRefreshed = false;
await invoke('library_refresh_album_metadata_from_files', { albumGroupKey: selectedAlbum.id });
showToast('Metadata refreshed from files', 'success');
albumMetadataRefreshed = true;
await handleTagEditorSaved();
} catch (err) {
console.error('Failed to refresh metadata:', err);
alert(`Failed to refresh metadata: ${err}`);
} finally {
refreshingAlbumMetadata = false;
}
}
async function fetchDiscogsArtwork() {
if (!selectedAlbum || fetchingDiscogsImages) return;
@@ -1483,6 +1531,7 @@
discogsImageOptions = [];
selectedDiscogsImage = null;
discogsImagePage = 0;
discogsFetchSuccessful = false;
const options = await invoke<DiscogsImageOption[]>('discogs_search_artwork', {
artist: selectedAlbum.artist,
@@ -1491,6 +1540,10 @@
});
discogsImageOptions = options;
discogsFetchSuccessful = options.length > 0;
if (options.length === 0) {
alert('No artwork found on Discogs for this album.');
}
console.log(`Found ${options.length} Discogs artwork options`);
} catch (err) {
console.error('Failed to fetch Discogs artwork:', err);
@@ -2243,7 +2296,7 @@
}
</script>
<div class="library-view" class:virtualized-active={(activeTab === 'albums' && !showHiddenAlbums && albums.length > 0) || (activeTab === 'artists' && artists.length > 0) || (activeTab === 'tracks' && tracks.length > 0)}>
<div class="library-view" class:virtualized-active={!selectedAlbum && ((activeTab === 'albums' && !showHiddenAlbums && albums.length > 0) || (activeTab === 'artists' && artists.length > 0) || (activeTab === 'tracks' && tracks.length > 0))}>
{#if selectedAlbum}
{@const albumSections = buildAlbumSections(albumTracks)}
{@const showDiscHeaders = albumSections.length > 1}
@@ -2480,7 +2533,7 @@
{/if}
<div class="settings-actions">
<button class="secondary-btn" onclick={toggleHiddenAlbumsView}>
<button class="btn btn-secondary" onclick={toggleHiddenAlbumsView}>
<span>{showHiddenAlbums ? 'Show Active Albums' : 'View Hidden Albums'}</span>
{#if hiddenAlbums.length > 0}
<span class="count">({hiddenAlbums.length})</span>
@@ -2488,7 +2541,7 @@
</button>
{#if hasDiscogsCredentials}
<button
class="secondary-btn"
class="btn btn-secondary"
onclick={handleFetchMissingArtwork}
disabled={fetchingArtwork || isOffline}
title={isOffline ? 'Artwork fetching unavailable offline' : ''}
@@ -2592,7 +2645,7 @@
<div class="albums-section">
<div class="section-header">
<h3>Hidden Albums ({hiddenAlbums.length})</h3>
<button class="secondary-btn" onclick={toggleHiddenAlbumsView}>
<button class="btn btn-secondary" onclick={toggleHiddenAlbumsView}>
<span>Back to Active Albums</span>
</button>
</div>
@@ -3085,140 +3138,170 @@
{/if}
</div>
<!-- Album Edit Modal -->
<!-- Album Settings Modal -->
{#if showAlbumEditModal && selectedAlbum}
<div class="modal-overlay" onclick={() => showAlbumEditModal = false}>
<div class="modal" onclick={(e: MouseEvent) => e.stopPropagation()}>
<div class="modal-header">
<h2>Edit Album</h2>
<h2>Album Settings</h2>
<button class="close-btn" onclick={() => showAlbumEditModal = false}>
<X size={20} />
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="album-title">Album Title</label>
<input
id="album-title"
type="text"
bind:value={editingAlbumTitle}
placeholder="Album title"
readonly
disabled
/>
<p class="form-hint">Album title editing coming soon</p>
</div>
<div class="form-group">
<label>Album Artwork</label>
<div class="artwork-row">
{#if selectedAlbum.artwork_path}
<img
src={getArtworkUrl(selectedAlbum.artwork_path)}
alt="Current artwork"
class="artwork-preview"
/>
{:else}
<div class="artwork-preview artwork-placeholder-mini">
<Disc3 size={24} />
</div>
{/if}
<div class="artwork-actions">
<button
class="discogs-btn"
onclick={handleSetAlbumArtwork}
disabled={updatingArtwork}
>
<Upload size={14} />
<span>{updatingArtwork ? 'Updating...' : 'Change Cover'}</span>
</button>
<button
class="discogs-btn"
onclick={fetchDiscogsArtwork}
disabled={fetchingDiscogsImages}
>
<img src="/discogs_icon.svg" alt="Discogs" class="discogs-icon" />
<span>{fetchingDiscogsImages ? 'Fetching...' : 'Fetch from Discogs'}</span>
</button>
<div class="album-header-grid">
<div class="album-text">
<div class="album-title">{selectedAlbum.title}</div>
<div class="album-artist">{selectedAlbum.artist}</div>
</div>
<div class="album-settings-actions">
<button
class="album-action-btn"
onclick={openTagEditorFromAlbumSettings}
title="Edit album metadata for LocalLibrary indexing and search"
>
<Edit3 size={18} />
<span>Edit album info</span>
</button>
<button
class="album-action-btn"
onclick={handleRefreshAlbumMetadataFromFiles}
disabled={refreshingAlbumMetadata}
title="Re-read embedded file tags and discard QBZ sidecar overrides"
>
{#if albumMetadataRefreshed && !refreshingAlbumMetadata}
<Check size={18} />
{:else}
<RefreshCw size={18} class={refreshingAlbumMetadata ? 'spinning' : ''} />
{/if}
<span>{refreshingAlbumMetadata ? 'Refreshing...' : 'Refresh metadata'}</span>
</button>
</div>
</div>
</div>
{#if discogsImageOptions.length > 0}
<div class="form-group">
<div class="discogs-header">
<label>Select Artwork from Discogs</label>
{#if discogsImageOptions.length > IMAGES_PER_PAGE}
<div class="carousel-controls">
<button
class="carousel-btn"
onclick={prevDiscogsPage}
disabled={!hasPrevDiscogsPages}
title="Previous"
>
<ChevronLeft size={16} />
</button>
<span class="page-indicator">
{discogsImagePage + 1} / {Math.ceil(discogsImageOptions.length / IMAGES_PER_PAGE)}
</span>
<button
class="carousel-btn"
onclick={nextDiscogsPage}
disabled={!hasMoreDiscogsPages}
title="Next"
>
<ChevronRight size={16} />
</button>
<div class="artwork-layout-header" class:discogs-active={discogsFetchSuccessful}>
<label>Album Artwork</label>
{#if discogsFetchSuccessful}
<div class="discogs-layout-label">Select Artwork from Discogs</div>
{/if}
</div>
<div class="artwork-layout" class:discogs-active={discogsFetchSuccessful}>
<div class="artwork-left">
<div class="artwork-row">
{#if selectedAlbum.artwork_path}
<img
src={getArtworkUrl(selectedAlbum.artwork_path)}
alt="Current artwork"
class="artwork-preview"
/>
{:else}
<div class="artwork-preview artwork-placeholder-mini">
<Disc3 size={24} />
</div>
{/if}
<div class="artwork-actions">
<button class="discogs-btn" onclick={handleSetAlbumArtwork} disabled={updatingArtwork}>
<Upload size={14} />
<span>{updatingArtwork ? 'Updating...' : 'Upload cover'}</span>
</button>
<button class="discogs-btn" onclick={fetchDiscogsArtwork} disabled={fetchingDiscogsImages}>
<img src="/discogs_icon.svg" alt="Discogs" class="discogs-icon" />
<span>{fetchingDiscogsImages ? 'Fetching...' : 'Get from Discogs'}</span>
</button>
</div>
</div>
</div>
{#if discogsFetchSuccessful}
<div class="discogs-panel">
{#if discogsImageOptions.length > IMAGES_PER_PAGE}
<div class="discogs-header">
<div class="carousel-controls">
<button
class="carousel-btn"
onclick={prevDiscogsPage}
disabled={!hasPrevDiscogsPages}
title="Previous"
>
<ChevronLeft size={16} />
</button>
<span class="page-indicator">
{discogsImagePage + 1} / {Math.ceil(discogsImageOptions.length / IMAGES_PER_PAGE)}
</span>
<button
class="carousel-btn"
onclick={nextDiscogsPage}
disabled={!hasMoreDiscogsPages}
title="Next"
>
<ChevronRight size={16} />
</button>
</div>
</div>
{/if}
<div class="discogs-options discogs-options-compact">
{#each paginatedDiscogsImages as option, i}
<button
class="discogs-option"
class:selected={selectedDiscogsImage === option.url}
onclick={() => selectedDiscogsImage = option.url}
title={option.release_title ? `${option.release_title}${option.release_year ? ` (${option.release_year})` : ''}` : ''}
>
<img src={option.url} alt={`Option ${discogsImagePage * IMAGES_PER_PAGE + i + 1}`} />
<div class="option-info">
{#if option.release_title}
<div class="release-title">
{option.release_title}{#if option.release_year} ({option.release_year}){/if}
</div>
{/if}
<div class="image-dims">{option.width}x{option.height}</div>
</div>
</button>
{/each}
</div>
</div>
{/if}
</div>
<div class="discogs-options">
{#each paginatedDiscogsImages as option, i}
<button
class="discogs-option"
class:selected={selectedDiscogsImage === option.url}
onclick={() => selectedDiscogsImage = option.url}
title={option.release_title ? `${option.release_title}${option.release_year ? ` (${option.release_year})` : ''}` : ''}
>
<img src={option.url} alt={`Option ${discogsImagePage * IMAGES_PER_PAGE + i + 1}`} />
<div class="option-info">
{#if option.release_title}
<div class="release-title">{option.release_title}{#if option.release_year} ({option.release_year}){/if}</div>
{/if}
<div class="image-dims">{option.width}x{option.height}</div>
</div>
</button>
{/each}
</div>
<p class="form-hint">Click an image to select it, then click Save</p>
</div>
{/if}
<div class="form-group">
<label class="toggle-label">
<input
type="checkbox"
bind:checked={editingAlbumHidden}
/>
<span>Hide this album from library</span>
</label>
<p class="form-hint">Hidden albums can be viewed from Settings</p>
</div>
</div>
<div class="modal-footer">
<button class="secondary-btn" onclick={() => showAlbumEditModal = false}>
Cancel
</button>
<button class="primary-btn" onclick={saveAlbumEdit}>
Save
</button>
<div class="footer-left">
<label class="toggle-label footer-toggle">
<input type="checkbox" bind:checked={editingAlbumHidden} />
<span>Hide this album from library</span>
</label>
<p class="form-hint footer-hint">Hidden albums can be viewed from Settings</p>
</div>
<div class="footer-actions">
<button class="btn btn-secondary" onclick={() => showAlbumEditModal = false}>
Cancel
</button>
<button class="btn btn-primary" onclick={saveAlbumEdit}>
Save
</button>
</div>
</div>
</div>
</div>
{/if}
<!-- LocalLibrary Tag Editor Modal -->
<LocalLibraryTagEditorModal
isOpen={showTagEditorModal}
album={selectedAlbum}
tracks={albumTracks}
onClose={() => (showTagEditorModal = false)}
onSaved={handleTagEditorSaved}
/>
<!-- Folder Settings Modal -->
<FolderSettingsModal
isOpen={showFolderSettingsModal}
@@ -3624,30 +3707,6 @@
align-items: center;
}
.secondary-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-subtle);
border-radius: 8px;
font-size: 13px;
cursor: pointer;
transition: all 150ms ease;
}
.secondary-btn:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--text-muted);
}
.secondary-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.discogs-hint {
font-size: 12px;
color: var(--text-muted);
@@ -4794,7 +4853,8 @@
}
.album-info .artist {
font-size: 16px;
font-size: 18px;
font-weight: 500;
color: var(--text-primary);
margin: 0 0 8px 0;
}
@@ -4805,6 +4865,9 @@
padding: 0;
text-align: left;
cursor: pointer;
font-size: 18px;
font-weight: 500;
color: var(--accent-primary);
}
.album-info .artist-link:hover {
@@ -4894,8 +4957,10 @@
}
.modal {
--album-settings-cover-size: 94px;
--discogs-panel-height: calc(var(--album-settings-cover-size) + 56px);
width: 100%;
max-width: 440px;
max-width: 704px;
max-height: 90vh;
overflow: hidden;
display: flex;
@@ -4941,10 +5006,65 @@
}
.modal-body {
padding: 24px;
padding: 18px 20px;
overflow-y: auto;
}
.album-header-grid {
display: grid;
grid-template-columns: 1fr auto;
gap: 16px;
align-items: start;
margin-bottom: 18px;
}
.album-title {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.album-artist {
margin-top: 6px;
font-size: 18px;
font-weight: 400;
color: var(--text-muted);
line-height: 1.25;
word-break: break-word;
}
.album-settings-actions {
display: flex;
flex-direction: column;
gap: 10px;
}
.album-action-btn {
width: 190px;
height: 40px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 0 12px;
background: var(--bg-tertiary);
border: 1px solid var(--bg-tertiary);
border-radius: 10px;
color: var(--text-primary);
cursor: pointer;
transition: all 150ms ease;
}
.album-action-btn:hover:not(:disabled) {
background: var(--bg-hover);
}
.album-action-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.form-group {
margin-bottom: 20px;
}
@@ -4957,6 +5077,29 @@
margin-bottom: 8px;
}
.artwork-layout-header {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
align-items: end;
margin-bottom: 8px;
}
.artwork-layout-header.discogs-active {
grid-template-columns: 1fr 1fr;
}
.artwork-layout-header label {
margin-bottom: 0;
}
.discogs-layout-label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-left: -8px;
}
.form-group input[type="text"] {
width: 100%;
padding: 10px 12px;
@@ -5009,9 +5152,49 @@
gap: 16px;
}
.artwork-layout {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
align-items: start;
}
.artwork-layout.discogs-active {
grid-template-columns: 1fr 1fr;
}
.discogs-panel {
min-width: 0;
border: 1px solid var(--bg-tertiary);
border-radius: 10px;
padding: 10px 12px;
background: var(--bg-secondary);
height: var(--discogs-panel-height);
display: flex;
flex-direction: column;
overflow: hidden;
margin-left: -8px;
width: calc(100% + 8px);
}
.discogs-placeholder {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 12px;
text-align: center;
padding: 10px;
}
.discogs-hint {
margin-top: 8px;
}
.artwork-preview {
width: 64px;
height: 64px;
width: var(--album-settings-cover-size);
height: var(--album-settings-cover-size);
border-radius: 6px;
object-fit: cover;
background: var(--bg-tertiary);
@@ -5068,6 +5251,20 @@
margin-top: 8px;
}
.discogs-options-compact {
grid-template-columns: repeat(3, var(--album-settings-cover-size));
grid-template-rows: var(--album-settings-cover-size);
justify-content: start;
gap: 10px;
margin-top: 0;
flex: 1;
overflow: hidden;
}
.discogs-panel .discogs-option:hover {
transform: none;
}
.discogs-option {
position: relative;
aspect-ratio: 1;
@@ -5127,7 +5324,7 @@
.discogs-header {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-end;
margin-bottom: 8px;
}
@@ -5171,40 +5368,26 @@
.modal-footer {
display: flex;
justify-content: flex-end;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid var(--bg-tertiary);
}
.secondary-btn,
.primary-btn {
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
.footer-left {
display: flex;
flex-direction: column;
gap: 6px;
max-width: 60%;
}
.secondary-btn {
background: transparent;
border: 1px solid var(--text-muted);
color: var(--text-secondary);
.footer-actions {
display: flex;
gap: 12px;
}
.secondary-btn:hover {
border-color: var(--text-primary);
color: var(--text-primary);
}
.primary-btn {
background: var(--accent-primary);
border: none;
color: white;
}
.primary-btn:hover {
background: var(--accent-hover);
.footer-hint {
margin-top: 0;
}
</style>
+7 -7
View File
@@ -411,13 +411,13 @@
.error-box {
text-align: center;
padding: 24px;
background-color: rgba(255, 107, 107, 0.1);
background-color: var(--danger-bg);
border-radius: 8px;
margin-bottom: 16px;
}
.error-box p {
color: #ff6b6b;
color: var(--danger);
margin-bottom: 8px;
}
@@ -430,8 +430,8 @@
.timeout-box {
text-align: center;
padding: 24px;
background-color: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.3);
background-color: var(--warning-bg);
border: 1px solid var(--warning-border);
border-radius: 8px;
margin-bottom: 16px;
}
@@ -439,7 +439,7 @@
.timeout-title {
font-size: 16px;
font-weight: 600;
color: #fbbf24;
color: var(--warning);
margin-bottom: 12px;
}
@@ -554,9 +554,9 @@
.error-message {
padding: 12px 16px;
background-color: rgba(255, 107, 107, 0.1);
background-color: var(--danger-bg);
border-radius: 8px;
color: #ff6b6b;
color: var(--danger);
font-size: 14px;
}
@@ -1,5 +1,5 @@
<script lang="ts">
import { ArrowLeft, Play, Shuffle, ListMusic, Search, X, ChevronDown, ChevronRight, ImagePlus, Edit3, BarChart2, Heart, CloudDownload, ListPlus } from 'lucide-svelte';
import { ArrowLeft, Play, Shuffle, ListMusic, Search, X, ChevronDown, ChevronRight, ChevronUp, ImagePlus, Edit3, BarChart2, Heart, CloudDownload, ListPlus, GripVertical } from 'lucide-svelte';
import AlbumMenu from '../AlbumMenu.svelte';
import PlaylistCollage from '../PlaylistCollage.svelte';
import PlaylistModal from '../PlaylistModal.svelte';
@@ -23,7 +23,12 @@
duration: number;
track_number: number;
performer?: { id?: number; name: string };
album?: { id: string; title: string; image: { small?: string; thumbnail?: string; large?: string } };
album?: {
id: string;
title: string;
image: { small?: string; thumbnail?: string; large?: string };
label?: { id: number; name: string };
};
hires: boolean;
maximum_bit_depth?: number;
maximum_sampling_rate?: number;
@@ -62,6 +67,9 @@
localTrackId?: number;
artworkPath?: string;
playlistTrackId?: number; // Qobuz playlist-specific ID for removal
label?: string; // Record label name from Qobuz
addedIndex?: number; // Original position in playlist (proxy for date added)
customPosition?: number; // User-defined position for custom arrange mode
}
// Local library track from backend
@@ -112,7 +120,7 @@
last_played_at?: number;
}
type SortField = 'default' | 'title' | 'artist' | 'album' | 'duration';
type SortField = 'default' | 'title' | 'artist' | 'album' | 'duration' | 'added' | 'label' | 'custom';
type SortOrder = 'asc' | 'desc';
interface Props {
@@ -203,6 +211,19 @@
let editModalOpen = $state(false);
let isFavorite = $state(false);
// Custom order state
let customOrderMap = $state<Map<string, number>>(new Map()); // "trackId:isLocal" -> position
let customOrderLoading = $state(false);
let isCustomOrderMode = $derived(sortBy === 'custom');
// Drag and drop state
let draggedTrackIdx = $state<number | null>(null);
let dragOverIdx = $state<number | null>(null);
// Batch selection state (for custom order mode)
let selectedTrackKeys = $state<Set<string>>(new Set()); // Set of "trackId:isLocal" keys
let isSelectionMode = $derived(isCustomOrderMode && selectedTrackKeys.size > 0);
// User ownership state (to show "Copy to Library" button for non-owned playlists)
let currentUserId = $state<number | null>(null);
let isOwnPlaylist = $derived(playlist !== null && currentUserId !== null && playlist.owner.id === currentUserId);
@@ -422,6 +443,8 @@
bitDepth: t.maximum_bit_depth,
samplingRate: t.maximum_sampling_rate,
isrc: t.isrc,
label: t.album?.label?.name,
addedIndex: idx,
}));
// Update duration
@@ -455,6 +478,8 @@
samplingRate: t.maximum_sampling_rate,
isrc: t.isrc,
playlistTrackId: t.playlist_track_id,
label: t.album?.label?.name,
addedIndex: idx,
}));
}
}
@@ -474,6 +499,7 @@
searchQuery = '';
playlistSettings = null;
isFavorite = false;
customOrderMap = new Map();
// Skip loading settings for pending playlists
if (playlistId < 0) {
@@ -489,6 +515,11 @@
customArtworkPath = settings.custom_artwork_path || null;
searchQuery = settings.last_search_query || '';
isFavorite = settings.is_favorite ?? false;
// Load custom order if in custom mode
if (sortBy === 'custom') {
await loadOrInitCustomOrder();
}
}
} catch (err) {
console.error('Failed to load playlist settings:', err);
@@ -543,6 +574,12 @@
sortBy = field;
sortOrder = order;
showSortMenu = false;
// When switching to custom mode, load or initialize custom order
if (field === 'custom') {
await loadOrInitCustomOrder();
}
try {
await invoke('playlist_set_sort', { playlistId, sortBy: field, sortOrder: order });
} catch (err) {
@@ -550,6 +587,292 @@
}
}
// Load custom order from backend or initialize if not exists
async function loadOrInitCustomOrder() {
if (playlistId < 0) return; // Skip for pending playlists
customOrderLoading = true;
try {
// Check if custom order exists
const hasOrder = await invoke<boolean>('playlist_has_custom_order', { playlistId });
if (hasOrder) {
// Load existing custom order
const orders = await invoke<[number, boolean, number][]>('playlist_get_custom_order', { playlistId });
const newMap = new Map<string, number>();
for (const [trackId, isLocal, position] of orders) {
newMap.set(`${trackId}:${isLocal}`, position);
}
customOrderMap = newMap;
} else {
// Initialize from current track arrangement
await initCustomOrderFromCurrentTracks();
}
} catch (err) {
console.error('Failed to load custom order:', err);
} finally {
customOrderLoading = false;
}
}
// Initialize custom order from the current track list
async function initCustomOrderFromCurrentTracks() {
// Get all tracks in current display order (before custom sort applied)
const allTracks = [...tracks];
const localTracksInPlaylist = localTracks.map((t, idx) => ({
...t,
playlist_position: idx
}));
// Build track ID list: (trackId, isLocal)
const trackIds: [number, boolean][] = [];
// Add Qobuz tracks first (in original order)
for (const t of allTracks) {
if (!t.isLocal) {
trackIds.push([t.id, false]);
}
}
// Add local tracks (by their position)
for (const t of localTracksInPlaylist) {
trackIds.push([t.id, true]);
}
// Save to backend
try {
await invoke('playlist_init_custom_order', { playlistId, trackIds });
// Update local state
const newMap = new Map<string, number>();
for (let i = 0; i < trackIds.length; i++) {
const [trackId, isLocal] = trackIds[i];
newMap.set(`${trackId}:${isLocal}`, i);
}
customOrderMap = newMap;
} catch (err) {
console.error('Failed to initialize custom order:', err);
}
}
// Move a track to a new position
async function moveTrack(trackId: number, isLocal: boolean, fromIndex: number, toIndex: number) {
if (fromIndex === toIndex) return;
// Optimistic update: reorder the map
const key = `${trackId}:${isLocal}`;
const newMap = new Map(customOrderMap);
// Adjust positions
for (const [k, pos] of newMap) {
if (k === key) {
newMap.set(k, toIndex);
} else if (fromIndex < toIndex) {
// Moving down: shift tracks between from+1 and to up
if (pos > fromIndex && pos <= toIndex) {
newMap.set(k, pos - 1);
}
} else {
// Moving up: shift tracks between to and from-1 down
if (pos >= toIndex && pos < fromIndex) {
newMap.set(k, pos + 1);
}
}
}
customOrderMap = newMap;
// Persist to backend
try {
await invoke('playlist_move_track', {
playlistId,
trackId: Math.abs(trackId),
isLocal,
newPosition: toIndex
});
} catch (err) {
console.error('Failed to move track:', err);
// Reload to get consistent state
await loadOrInitCustomOrder();
}
}
// Helper to move track up one position
function moveTrackUp(track: DisplayTrack, currentIndex: number) {
if (currentIndex === 0) return;
const isLocal = track.isLocal ?? false;
const trackId = isLocal ? Math.abs(track.id) : track.id;
moveTrack(trackId, isLocal, currentIndex, currentIndex - 1);
}
// Helper to move track down one position
function moveTrackDown(track: DisplayTrack, currentIndex: number) {
if (currentIndex >= displayTracks.length - 1) return;
const isLocal = track.isLocal ?? false;
const trackId = isLocal ? Math.abs(track.id) : track.id;
moveTrack(trackId, isLocal, currentIndex, currentIndex + 1);
}
// Drag and drop handlers
function handleDragStart(e: DragEvent, idx: number) {
if (!isCustomOrderMode) return;
draggedTrackIdx = idx;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(idx));
}
}
function handleDragOver(e: DragEvent, idx: number) {
if (!isCustomOrderMode || draggedTrackIdx === null) return;
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'move';
}
dragOverIdx = idx;
}
function handleDragLeave() {
dragOverIdx = null;
}
function handleDragEnd() {
draggedTrackIdx = null;
dragOverIdx = null;
}
function handleDrop(e: DragEvent, toIdx: number) {
e.preventDefault();
if (!isCustomOrderMode || draggedTrackIdx === null) return;
const fromIdx = draggedTrackIdx;
if (fromIdx !== toIdx) {
const track = displayTracks[fromIdx];
const isLocal = track.isLocal ?? false;
const trackId = isLocal ? Math.abs(track.id) : track.id;
moveTrack(trackId, isLocal, fromIdx, toIdx);
}
draggedTrackIdx = null;
dragOverIdx = null;
}
// === Batch Selection Functions ===
function getTrackKey(track: DisplayTrack): string {
const isLocal = track.isLocal ?? false;
const trackId = isLocal ? Math.abs(track.id) : track.id;
return `${trackId}:${isLocal}`;
}
function toggleTrackSelection(track: DisplayTrack) {
const key = getTrackKey(track);
const newSet = new Set(selectedTrackKeys);
if (newSet.has(key)) {
newSet.delete(key);
} else {
newSet.add(key);
}
selectedTrackKeys = newSet;
}
function clearSelection() {
selectedTrackKeys = new Set();
}
function selectAllTracks() {
const newSet = new Set<string>();
for (const track of displayTracks) {
newSet.add(getTrackKey(track));
}
selectedTrackKeys = newSet;
}
// Move all selected tracks up one position (as a group)
async function moveSelectedUp() {
if (selectedTrackKeys.size === 0) return;
// Get indices of selected tracks (sorted)
const selectedIndices: number[] = [];
displayTracks.forEach((track, idx) => {
if (selectedTrackKeys.has(getTrackKey(track))) {
selectedIndices.push(idx);
}
});
selectedIndices.sort((a, b) => a - b);
// Can't move up if first selected is already at top
if (selectedIndices[0] === 0) return;
// Build new order: swap each selected with the one above
const currentOrder = displayTracks.map(t => ({
id: t.isLocal ? Math.abs(t.id) : t.id,
isLocal: t.isLocal ?? false
}));
// Move from top to bottom to avoid conflicts
for (const idx of selectedIndices) {
const newIdx = idx - 1;
[currentOrder[newIdx], currentOrder[idx]] = [currentOrder[idx], currentOrder[newIdx]];
}
// Save new order
const orders: [number, boolean, number][] = currentOrder.map((item, pos) => [item.id, item.isLocal, pos]);
try {
await invoke('playlist_set_custom_order', { playlistId, orders });
// Update local map
const newMap = new Map<string, number>();
orders.forEach(([id, isLocal, pos]) => {
newMap.set(`${id}:${isLocal}`, pos);
});
customOrderMap = newMap;
} catch (err) {
console.error('Failed to move selected tracks:', err);
}
}
// Move all selected tracks down one position (as a group)
async function moveSelectedDown() {
if (selectedTrackKeys.size === 0) return;
// Get indices of selected tracks (sorted descending for moving down)
const selectedIndices: number[] = [];
displayTracks.forEach((track, idx) => {
if (selectedTrackKeys.has(getTrackKey(track))) {
selectedIndices.push(idx);
}
});
selectedIndices.sort((a, b) => b - a); // Descending
// Can't move down if last selected is already at bottom
if (selectedIndices[0] === displayTracks.length - 1) return;
// Build new order: swap each selected with the one below
const currentOrder = displayTracks.map(t => ({
id: t.isLocal ? Math.abs(t.id) : t.id,
isLocal: t.isLocal ?? false
}));
// Move from bottom to top to avoid conflicts
for (const idx of selectedIndices) {
const newIdx = idx + 1;
[currentOrder[idx], currentOrder[newIdx]] = [currentOrder[newIdx], currentOrder[idx]];
}
// Save new order
const orders: [number, boolean, number][] = currentOrder.map((item, pos) => [item.id, item.isLocal, pos]);
try {
await invoke('playlist_set_custom_order', { playlistId, orders });
// Update local map
const newMap = new Map<string, number>();
orders.forEach(([id, isLocal, pos]) => {
newMap.set(`${id}:${isLocal}`, pos);
});
customOrderMap = newMap;
} catch (err) {
console.error('Failed to move selected tracks:', err);
}
}
async function selectCustomArtwork() {
try {
const selected = await open({
@@ -662,6 +985,29 @@
case 'duration':
cmp = a.durationSeconds - b.durationSeconds;
break;
case 'added':
// Use original index as proxy for date added
// ASC = newest first (higher index = more recent), DESC = oldest first
cmp = (b.addedIndex ?? 0) - (a.addedIndex ?? 0);
break;
case 'label':
const labelA = a.label || '';
const labelB = b.label || '';
// Tracks without label (local tracks) go to end
if (!labelA && labelB) return 1;
if (labelA && !labelB) return -1;
cmp = labelA.localeCompare(labelB);
break;
case 'custom':
// Get positions from customOrderMap
const aIsLocal = a.isLocal ?? false;
const bIsLocal = b.isLocal ?? false;
const aKey = `${aIsLocal ? Math.abs(a.id) : a.id}:${aIsLocal}`;
const bKey = `${bIsLocal ? Math.abs(b.id) : b.id}:${bIsLocal}`;
const aPos = customOrderMap.get(aKey) ?? a.addedIndex ?? 0;
const bPos = customOrderMap.get(bKey) ?? b.addedIndex ?? 0;
cmp = aPos - bPos;
break;
}
return sortOrder === 'desc' ? -cmp : cmp;
});
@@ -676,6 +1022,9 @@
{ field: 'artist', label: 'Artist' },
{ field: 'album', label: 'Album' },
{ field: 'duration', label: 'Duration' },
{ field: 'added', label: 'Added Recently' },
{ field: 'label', label: 'Label' },
{ field: 'custom', label: 'Custom Order' },
];
function formatDuration(seconds: number): string {
@@ -1160,7 +1509,32 @@
<!-- Track List -->
<div class="track-list">
{#if isCustomOrderMode}
<div class="batch-controls">
<div class="batch-left">
{#if selectedTrackKeys.size > 0}
<span class="selection-count">{selectedTrackKeys.size} selected</span>
<button class="batch-btn" onclick={clearSelection}>Clear</button>
{:else}
<button class="batch-btn" onclick={selectAllTracks}>Select All</button>
{/if}
</div>
{#if selectedTrackKeys.size > 0}
<div class="batch-right">
<button class="batch-btn" onclick={moveSelectedUp} title="Move selected up">
<ChevronUp size={14} /> Move Up
</button>
<button class="batch-btn" onclick={moveSelectedDown} title="Move selected down">
<ChevronDown size={14} /> Move Down
</button>
</div>
{/if}
</div>
{/if}
<div class="track-list-header">
{#if isCustomOrderMode}
<div class="col-checkbox"></div>
{/if}
<div class="col-number">#</div>
<div class="col-title">Title</div>
<div class="col-album">Album</div>
@@ -1178,12 +1552,53 @@
? (track.localTrackId !== undefined && activeTrackId === track.localTrackId)
: activeTrackId === track.id
)}
{@const isTrackPlaying = isActiveTrack && isPlaybackActive}
{@const available = isTrackAvailable(track)}
<div
class="track-row-wrapper"
class:unavailable={!available}
class:custom-order-mode={isCustomOrderMode}
class:dragging={draggedTrackIdx === idx}
class:drag-over={dragOverIdx === idx && draggedTrackIdx !== idx}
title={!available ? $t('offline.trackNotAvailable') : undefined}
draggable={isCustomOrderMode}
ondragstart={(e) => handleDragStart(e, idx)}
ondragover={(e) => handleDragOver(e, idx)}
ondragleave={handleDragLeave}
ondragend={handleDragEnd}
ondrop={(e) => handleDrop(e, idx)}
>
{#if isCustomOrderMode}
{@const trackKey = getTrackKey(track)}
<label class="track-checkbox" onclick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedTrackKeys.has(trackKey)}
onchange={() => toggleTrackSelection(track)}
/>
</label>
<div class="reorder-controls">
<button
class="reorder-btn"
onclick={() => moveTrackUp(track, idx)}
disabled={idx === 0}
title="Move up"
>
<ChevronUp size={16} />
</button>
<div class="drag-handle" title="Drag to reorder">
<GripVertical size={16} />
</div>
<button
class="reorder-btn"
onclick={() => moveTrackDown(track, idx)}
disabled={idx === displayTracks.length - 1}
title="Move down"
>
<ChevronDown size={16} />
</button>
</div>
{/if}
<TrackRow
trackId={track.isLocal ? undefined : track.id}
number={idx + 1}
@@ -1196,7 +1611,7 @@
: track.hires
? 'Hi-Res'
: '-'}
isPlaying={isActiveTrack}
isPlaying={isTrackPlaying}
isLocal={track.isLocal}
hideFavorite={track.isLocal}
hideDownload={track.isLocal}
@@ -1458,12 +1873,12 @@
.artwork-btn.artwork-clear {
width: 36px;
height: 36px;
background: rgba(255, 0, 0, 0.3);
border-color: rgba(255, 100, 100, 0.5);
background: var(--danger-bg);
border-color: var(--danger-border);
}
.artwork-btn.artwork-clear:hover {
background: rgba(255, 0, 0, 0.5);
background: var(--danger-hover);
}
.metadata {
@@ -1785,4 +2200,135 @@
filter: grayscale(100%);
}
/* Custom order mode */
.track-row-wrapper.custom-order-mode {
padding-left: 0;
}
.reorder-controls {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
margin-right: 8px;
flex-shrink: 0;
}
.reorder-btn {
background: transparent;
border: none;
color: var(--text-secondary, #888);
cursor: pointer;
padding: 2px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.15s, color 0.15s;
}
.reorder-btn:hover:not(:disabled) {
background: var(--hover-bg, rgba(255, 255, 255, 0.1));
color: var(--text-primary, #fff);
}
.reorder-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.drag-handle {
color: var(--text-secondary, #888);
cursor: grab;
padding: 2px;
display: flex;
align-items: center;
justify-content: center;
}
.drag-handle:active {
cursor: grabbing;
}
/* Drag and drop styles */
.track-row-wrapper.dragging {
opacity: 0.5;
background: var(--drag-bg, rgba(99, 102, 241, 0.2));
}
.track-row-wrapper.drag-over {
border-top: 2px solid var(--accent-color, #6366f1);
margin-top: -2px;
}
.track-row-wrapper[draggable="true"] {
cursor: grab;
}
.track-row-wrapper[draggable="true"]:active {
cursor: grabbing;
}
/* Batch selection controls */
.batch-controls {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: var(--bg-tertiary);
border-radius: 8px;
margin-bottom: 8px;
}
.batch-left, .batch-right {
display: flex;
align-items: center;
gap: 8px;
}
.selection-count {
font-size: 13px;
color: var(--text-secondary);
}
.batch-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
border-radius: 6px;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
}
.batch-btn:hover {
background: var(--hover-bg, rgba(255, 255, 255, 0.1));
color: var(--text-primary);
}
.col-checkbox {
width: 24px;
flex-shrink: 0;
}
.track-checkbox {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
flex-shrink: 0;
cursor: pointer;
}
.track-checkbox input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--accent-color, #6366f1);
}
</style>
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { ArrowLeft, Filter, ArrowUpDown, LayoutGrid, List, GripVertical, EyeOff, Eye, BarChart2, Play, Pencil, Search, X, Cloud, CloudOff, Wifi, Heart, Folder, FolderPlus, ChevronRight, ChevronDown, Trash2, Star, Music, Disc, Library, Info } from 'lucide-svelte';
import { ArrowLeft, Filter, ArrowUpDown, LayoutGrid, List, GripVertical, EyeOff, Eye, BarChart2, Play, Pencil, Search, X, Cloud, CloudOff, Wifi, Heart, Folder, FolderPlus, ChevronRight, ChevronDown, ChevronUp, Trash2, Star, Music, Disc, Library, Info } from 'lucide-svelte';
import PlaylistCollage from '../PlaylistCollage.svelte';
import PlaylistModal from '../PlaylistModal.svelte';
import FolderEditModal from '../FolderEditModal.svelte';
@@ -459,26 +459,17 @@
const draggedIndex = currentOrder.indexOf(draggedId);
const targetIndex = currentOrder.indexOf(targetId);
if (draggedIndex === -1 || targetIndex === -1) return;
if (draggedIndex === -1 || targetIndex === -1) {
draggedId = null;
dragOverId = null;
return;
}
// Remove dragged item and insert at target position
currentOrder.splice(draggedIndex, 1);
currentOrder.splice(targetIndex, 0, draggedId);
// Save new order
try {
await invoke('playlist_reorder', { playlistIds: currentOrder });
// Update local settings
const updated = new Map(playlistSettings);
currentOrder.forEach((id, index) => {
const existing = updated.get(id);
updated.set(id, { ...existing, qobuz_playlist_id: id, hidden: existing?.hidden ?? false, position: index });
});
playlistSettings = updated;
onPlaylistsChanged?.();
} catch (err) {
console.error('Failed to reorder playlists:', err);
}
await savePlaylistOrder(currentOrder);
draggedId = null;
dragOverId = null;
@@ -490,6 +481,51 @@
dragOverFolderId = null;
}
// Move playlist up one position
async function movePlaylistUp(playlistId: number) {
if (sort !== 'custom') return;
const currentOrder = displayPlaylists.map(p => p.id);
const currentIndex = currentOrder.indexOf(playlistId);
if (currentIndex <= 0) return;
// Swap with previous
[currentOrder[currentIndex - 1], currentOrder[currentIndex]] =
[currentOrder[currentIndex], currentOrder[currentIndex - 1]];
await savePlaylistOrder(currentOrder);
}
// Move playlist down one position
async function movePlaylistDown(playlistId: number) {
if (sort !== 'custom') return;
const currentOrder = displayPlaylists.map(p => p.id);
const currentIndex = currentOrder.indexOf(playlistId);
if (currentIndex < 0 || currentIndex >= currentOrder.length - 1) return;
// Swap with next
[currentOrder[currentIndex], currentOrder[currentIndex + 1]] =
[currentOrder[currentIndex + 1], currentOrder[currentIndex]];
await savePlaylistOrder(currentOrder);
}
// Helper to save playlist order
async function savePlaylistOrder(newOrder: number[]) {
try {
await invoke('playlist_reorder', { playlistIds: newOrder });
// Update local settings
const updated = new Map(playlistSettings);
newOrder.forEach((id, index) => {
const existing = updated.get(id);
if (existing) updated.set(id, { ...existing, position: index });
});
playlistSettings = updated;
onPlaylistsChanged?.();
} catch (err) {
console.error('Failed to reorder playlists:', err);
}
}
// === Folder Navigation ===
function navigateToFolder(folderId: string | null) {
@@ -922,11 +958,30 @@
ondrop={(e) => !isUnavailable && handleDrop(e, playlist.id)}
ondragend={handleDragEnd}
>
<!-- Top row: drag handle only (when in custom sort mode) -->
<!-- Top row: reorder controls (when in custom sort mode) -->
{#if sort === 'custom' && !isUnavailable}
{@const playlistIndex = displayPlaylists.findIndex(p => p.id === playlist.id)}
<div class="grid-item-header">
<div class="drag-handle">
<GripVertical size={14} />
<div class="reorder-controls">
<button
class="reorder-btn"
onclick={(e) => { e.stopPropagation(); movePlaylistUp(playlist.id); }}
disabled={playlistIndex === 0}
title="Move up"
>
<ChevronUp size={14} />
</button>
<div class="drag-handle">
<GripVertical size={14} />
</div>
<button
class="reorder-btn"
onclick={(e) => { e.stopPropagation(); movePlaylistDown(playlist.id); }}
disabled={playlistIndex === displayPlaylists.length - 1}
title="Move down"
>
<ChevronDown size={14} />
</button>
</div>
</div>
{/if}
@@ -1026,8 +1081,27 @@
title={isUnavailable ? $t('offline.viewOnly') : undefined}
>
{#if sort === 'custom' && !isUnavailable}
<div class="drag-handle">
<GripVertical size={16} />
{@const playlistIndex = displayPlaylists.findIndex(p => p.id === playlist.id)}
<div class="reorder-controls horizontal">
<button
class="reorder-btn"
onclick={(e) => { e.stopPropagation(); movePlaylistUp(playlist.id); }}
disabled={playlistIndex === 0}
title="Move up"
>
<ChevronUp size={14} />
</button>
<div class="drag-handle">
<GripVertical size={16} />
</div>
<button
class="reorder-btn"
onclick={(e) => { e.stopPropagation(); movePlaylistDown(playlist.id); }}
disabled={playlistIndex === displayPlaylists.length - 1}
title="Move down"
>
<ChevronDown size={14} />
</button>
</div>
{/if}
<div class="artwork-small">
@@ -1534,56 +1608,6 @@
gap: 12px;
}
.btn-secondary,
.btn-primary,
.btn-danger {
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: background-color 150ms ease, opacity 150ms ease;
}
.btn-secondary {
background: var(--bg-tertiary);
border: 1px solid var(--alpha-10);
color: var(--text-primary);
}
.btn-secondary:hover {
background: var(--bg-hover);
}
.btn-primary {
background: var(--accent-primary);
border: none;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-secondary);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-danger {
background: transparent;
border: 1px solid var(--error);
color: var(--error);
}
.btn-danger:hover {
background: var(--error);
color: white;
}
.controls {
display: flex;
align-items: center;
@@ -1834,6 +1858,42 @@
cursor: grabbing;
}
/* Reorder controls for custom sort mode */
.reorder-controls {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
}
.reorder-controls.horizontal {
flex-direction: row;
margin-right: 8px;
}
.reorder-btn {
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 2px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.15s, color 0.15s;
}
.reorder-btn:hover:not(:disabled) {
background: var(--hover-bg, rgba(255, 255, 255, 0.1));
color: var(--text-primary, #fff);
}
.reorder-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.drag-handle-placeholder {
width: 18px;
}
+109 -7
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { writeText as copyToClipboard } from '@tauri-apps/plugin-clipboard-manager';
import { ArrowLeft, ChevronDown, ChevronRight, Loader2 } from 'lucide-svelte';
import Toggle from '../Toggle.svelte';
import Dropdown from '../Dropdown.svelte';
@@ -1552,6 +1553,23 @@
console.warn('Failed to set zoom:', err);
}
}
// Flatpak copyable command state
let copiedCommands = $state<Record<string, boolean>>({});
async function copyCommand(key: string, command: string) {
try {
await copyToClipboard(command);
copiedCommands[key] = true;
setTimeout(() => { copiedCommands[key] = false; }, 1200);
} catch {
try {
await navigator.clipboard.writeText(command);
copiedCommands[key] = true;
setTimeout(() => { copiedCommands[key] = false; }, 1200);
} catch {}
}
}
</script>
<div class="settings-view" bind:this={settingsViewEl}>
@@ -1939,6 +1957,7 @@
</div>
</section>
<!-- Offline Library Section -->
<section class="section" bind:this={downloadsSection}>
<h3 class="section-title">{$t('settings.offlineLibrary.title')}</h3>
@@ -1953,13 +1972,6 @@
{/if}
</span>
</div>
<div class="setting-row">
<div class="setting-with-description">
<span class="setting-label">Show in Local Library</span>
<span class="setting-description">Display offline Qobuz™ tracks in your Local Library</span>
</div>
<Toggle enabled={showQobuzDownloadsInLibrary} onchange={handleShowDownloadsChange} />
</div>
<div class="setting-row">
<div class="setting-with-description">
<span class="setting-label">Repair Offline Library</span>
@@ -1985,6 +1997,96 @@
</div>
</section>
<!-- Flatpak Section (only shown when running in Flatpak) -->
{#if isFlatpak}
<section class="section flatpak-section">
<h3 class="section-title">Flatpak Sandbox</h3>
<div class="flatpak-info">
<p class="flatpak-intro">
QBZ is running inside a Flatpak sandbox. For offline libraries on NAS, network mounts, or external disks, direct filesystem access is required.
</p>
<div class="flatpak-guide">
<h4>Grant Filesystem Access</h4>
<p>Use <strong>Flatseal</strong> (GUI) or run this command for each folder you want to add:</p>
<div class="copyable-command">
<pre class="code-block">flatpak override --user --filesystem=/path/to/your/music com.blitzfc.qbz</pre>
<button class="copy-btn" onclick={() => copyCommand('fs-basic', 'flatpak override --user --filesystem=/path/to/your/music com.blitzfc.qbz')}>
{copiedCommands['fs-basic'] ? 'Copied!' : 'Copy'}
</button>
</div>
<h4>Examples</h4>
<div class="copyable-command">
<pre class="code-block"># CIFS / Samba mount
flatpak override --user --filesystem=/mnt/nas com.blitzfc.qbz
# SSHFS mount
flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz
# Custom folder (edit as needed)
flatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz</pre>
<button class="copy-btn" onclick={() => copyCommand('fs-examples', `# CIFS / Samba mount\nflatpak override --user --filesystem=/mnt/nas com.blitzfc.qbz\n\n# SSHFS mount\nflatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz\n\n# Custom folder (edit as needed)\nflatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz`)}>
{copiedCommands['fs-examples'] ? 'Copied!' : 'Copy'}
</button>
</div>
<p class="flatpak-note">
<strong>Note:</strong> This setting is persistent and survives reboots and updates.<br />
<strong>Tip:</strong> You can repeat the command for as many folders as you need.
</p>
</div>
<div class="flatpak-guide" style="margin-top:2em;">
<h4>Chromecast &amp; DLNA Device Discovery</h4>
<p>
To detect Chromecast and DLNA devices on your network, you must grant network sharing permissions to the app:
</p>
<div class="copyable-command">
<pre class="code-block">flatpak override --user --share=network com.blitzfc.qbz</pre>
<button class="copy-btn" onclick={() => copyCommand('network', 'flatpak override --user --share=network com.blitzfc.qbz')}>
{copiedCommands['network'] ? 'Copied!' : 'Copy'}
</button>
</div>
<p class="flatpak-note">
<strong>Note:</strong> Without this, device discovery will not work.<br />
You only need to do this once.
</p>
</div>
</div>
</section>
{/if}
<style>
.copyable-command {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
}
.copyable-command .code-block {
margin: 0;
font-size: 13px;
background: var(--bg-tertiary);
border-radius: 6px;
padding: 8px 12px;
user-select: all;
min-width: 0;
flex: 1;
white-space: pre-wrap;
word-break: break-all;
}
.copy-btn {
background: var(--accent-primary);
color: white;
border: none;
border-radius: 6px;
padding: 6px 14px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.copy-btn:hover {
background: var(--accent-secondary);
}
</style>
<!-- Library Section -->
<section class="section" bind:this={librarySection}>
<h3 class="section-title">{$t('settings.library.title')}</h3>
+175 -3
View File
@@ -16,12 +16,21 @@ export interface CastDevice {
port: number;
}
export interface CastPositionInfo {
positionSecs: number;
durationSecs: number;
transportState: string;
}
interface CastState {
isConnected: boolean;
protocol: CastProtocol | null;
device: CastDevice | null;
isPlaying: boolean;
currentTrackId: number | null;
// Position tracking for DLNA
positionSecs: number;
durationSecs: number;
}
let state: CastState = {
@@ -29,9 +38,25 @@ let state: CastState = {
protocol: null,
device: null,
isPlaying: false,
currentTrackId: null
currentTrackId: null,
positionSecs: 0,
durationSecs: 0
};
// Polling interval for DLNA position updates
let positionPollInterval: ReturnType<typeof setInterval> | null = null;
const POSITION_POLL_INTERVAL_MS = 1000;
// Callback for when track ends (for auto-advance)
let onCastTrackEnded: (() => Promise<void>) | null = null;
// Callback for when cast disconnects (to reset player state)
let onCastDisconnected: (() => void) | null = null;
// Track end detection state
let lastTransportState: string = '';
let trackEndDetected = false;
const listeners = new Set<() => void>();
function notifyListeners(): void {
@@ -115,6 +140,9 @@ export async function connectToDevice(device: CastDevice, protocol: CastProtocol
export async function disconnect(): Promise<void> {
if (!state.isConnected || !state.protocol) return;
// Stop position polling first
stopPositionPolling();
try {
await castStop();
switch (state.protocol) {
@@ -137,9 +165,23 @@ export async function disconnect(): Promise<void> {
protocol: null,
device: null,
isPlaying: false,
currentTrackId: null
currentTrackId: null,
positionSecs: 0,
durationSecs: 0
};
notifyListeners();
// Notify player to reset its state
if (onCastDisconnected) {
onCastDisconnected();
}
}
/**
* Set callback for when cast disconnects
*/
export function setOnCastDisconnected(callback: () => void): void {
onCastDisconnected = callback;
}
/**
@@ -202,9 +244,16 @@ export async function castTrack(
state = {
...state,
isPlaying: true,
currentTrackId: trackId
currentTrackId: trackId,
positionSecs: 0,
durationSecs: metadata.durationSecs || 0
};
notifyListeners();
// Start position polling for DLNA and Chromecast
if (state.protocol === 'dlna' || state.protocol === 'chromecast') {
startPositionPolling();
}
} catch (err) {
console.error('[CastStore] Failed to cast track:', err);
throw err;
@@ -333,3 +382,126 @@ export async function castSetVolume(volume: number): Promise<void> {
console.error('[CastStore] Failed to set volume:', err);
}
}
/**
* Get current cast position (for seekbar display)
*/
export function getCastPosition(): { positionSecs: number; durationSecs: number } {
return {
positionSecs: state.positionSecs,
durationSecs: state.durationSecs
};
}
/**
* Set callback for when cast track ends (for auto-advance)
*/
export function setOnCastTrackEnded(callback: (() => Promise<void>) | null): void {
onCastTrackEnded = callback;
}
/**
* Start polling for cast position updates (DLNA and Chromecast)
*/
export function startPositionPolling(): void {
if (positionPollInterval) return;
if (state.protocol !== 'dlna' && state.protocol !== 'chromecast') return;
console.log(`[CastStore] Starting ${state.protocol?.toUpperCase()} position polling`);
trackEndDetected = false;
lastTransportState = 'PLAYING';
positionPollInterval = setInterval(async () => {
if (!state.isConnected || (state.protocol !== 'dlna' && state.protocol !== 'chromecast')) {
stopPositionPolling();
return;
}
try {
let positionSecs = 0;
let durationSecs = 0;
let transportState = 'PLAYING';
let idleReason: string | null = null;
if (state.protocol === 'dlna') {
const positionInfo = await invoke<{
position_secs: number;
duration_secs: number;
transport_state: string;
}>('dlna_get_position');
positionSecs = positionInfo.position_secs;
durationSecs = positionInfo.duration_secs;
transportState = positionInfo.transport_state;
} else if (state.protocol === 'chromecast') {
const positionInfo = await invoke<{
position_secs: number;
duration_secs: number;
player_state: string;
idle_reason: string | null;
}>('cast_get_position');
positionSecs = positionInfo.position_secs;
durationSecs = positionInfo.duration_secs;
transportState = positionInfo.player_state;
idleReason = positionInfo.idle_reason;
}
const isNowPlaying = transportState === 'PLAYING';
state = {
...state,
positionSecs,
durationSecs,
isPlaying: isNowPlaying
};
notifyListeners();
// Detect track ended based on protocol
let trackEnded = false;
if (state.protocol === 'dlna') {
// DLNA: was PLAYING, now STOPPED or NO_MEDIA_PRESENT
trackEnded = lastTransportState === 'PLAYING' &&
(transportState === 'STOPPED' || transportState === 'NO_MEDIA_PRESENT');
} else if (state.protocol === 'chromecast') {
// Chromecast: IDLE state with FINISHED reason
trackEnded = transportState === 'IDLE' && idleReason === 'FINISHED';
}
if (trackEnded && !trackEndDetected) {
console.log(`[CastStore] ${state.protocol?.toUpperCase()} track ended, state:`, transportState, 'idle_reason:', idleReason);
trackEndDetected = true;
if (onCastTrackEnded) {
try {
await onCastTrackEnded();
} catch (err) {
console.error('[CastStore] Failed to auto-advance:', err);
}
}
}
// Reset track end detection when a new track starts playing
if (transportState === 'PLAYING' && trackEndDetected) {
trackEndDetected = false;
}
lastTransportState = transportState;
} catch (err) {
// Silently ignore polling errors (device may be temporarily unavailable)
}
}, POSITION_POLL_INTERVAL_MS);
}
/**
* Stop polling for position updates
*/
export function stopPositionPolling(): void {
if (positionPollInterval) {
console.log('[CastStore] Stopping position polling');
clearInterval(positionPollInterval);
positionPollInterval = null;
}
}
+31 -1
View File
@@ -13,7 +13,11 @@ import {
castPause,
castSeek,
castSetVolume,
castStop
castStop,
getCastPosition,
subscribe as subscribeToCast,
setOnCastTrackEnded,
setOnCastDisconnected
} from '$lib/stores/castStore';
// ============ Types ============
@@ -64,6 +68,7 @@ let volume = 75;
let isFavorite = false;
// Event listener state (replaces polling)
let eventUnlisten: UnlistenFn | null = null;
let castUnsubscribe: (() => void) | null = null;
let isAdvancingTrack = false;
let isSkipping = false;
let queueEnded = false;
@@ -350,6 +355,8 @@ export async function stop(): Promise<void> {
*/
export function setOnTrackEnded(callback: () => Promise<void>): void {
onTrackEnded = callback;
// Also set the same callback for cast track ended (DLNA auto-advance)
setOnCastTrackEnded(callback);
}
/**
@@ -401,6 +408,25 @@ export async function startPolling(): Promise<void> {
} catch (err) {
console.error('Failed to start playback event listener:', err);
}
// Also subscribe to cast store for DLNA position updates
if (!castUnsubscribe) {
castUnsubscribe = subscribeToCast(() => {
if (isCasting()) {
const castPos = getCastPosition();
if (castPos.positionSecs !== currentTime) {
currentTime = castPos.positionSecs;
notifyListeners();
}
}
});
// Set callback for when cast disconnects - reset player state
setOnCastDisconnected(() => {
isPlaying = false;
notifyListeners();
});
}
}
/**
@@ -412,6 +438,10 @@ export function stopPolling(): void {
eventUnlisten = null;
console.log('Stopped listening for playback events');
}
if (castUnsubscribe) {
castUnsubscribe();
castUnsubscribe = null;
}
}
/**
-1
View File
@@ -3075,7 +3075,6 @@
height: calc(100vh - 136px); /* 104px NowPlayingBar + 32px TitleBar */
overflow: hidden;
position: relative;
z-index: 1;
}
.main-content {
+9 -9
View File
@@ -364,7 +364,7 @@
width: 100%;
height: 100%;
background: transparent;
color: white;
color: var(--text-primary);
user-select: none;
overflow: hidden;
padding: 8px;
@@ -441,8 +441,8 @@
}
.restore-btn:hover {
background: rgba(99, 102, 241, 0.3);
color: #a5b4fc;
background: var(--accent-bg);
color: var(--accent-primary);
}
.track-info {
@@ -461,7 +461,7 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #fff;
color: var(--text-primary);
line-height: 1.4;
}
@@ -513,7 +513,7 @@
.progress-fill {
height: 100%;
background: #6366f1;
background: var(--accent-primary);
border-radius: 2px;
transition: width 100ms linear;
}
@@ -568,7 +568,7 @@
width: 10px;
height: 10px;
border-radius: 50%;
background: #fff;
background: var(--text-primary);
opacity: 0;
transition: opacity 150ms ease;
}
@@ -584,7 +584,7 @@
padding: 2px 6px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
color: var(--text-primary);
font-size: 11px;
opacity: 0;
transform: translateY(4px);
@@ -612,7 +612,7 @@
.ctrl-btn:hover {
background: var(--alpha-8);
color: #fff;
color: var(--text-primary);
}
.ctrl-btn:active {
@@ -620,7 +620,7 @@
}
.ctrl-btn.active {
color: #818cf8;
color: var(--accent-primary);
}
.ctrl-btn.play {
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 6.75C2 5.50736 3.00736 4.5 4.25 4.5H23.75C24.9926 4.5 26 5.50736 26 6.75V21.25C26 22.4926 24.9926 23.5 23.75 23.5H4.25C3.00736 23.5 2 22.4926 2 21.25V6.75ZM5.5 11.25C5.5 11.6642 5.83579 12 6.25 12C10.2577 12 13.505 15.2417 13.505 19.2476C13.505 19.6618 13.8408 19.9976 14.255 19.9976C14.6692 19.9976 15.005 19.6618 15.005 19.2476C15.005 14.4115 11.0843 10.5 6.25 10.5C5.83579 10.5 5.5 10.8358 5.5 11.25ZM6.25 13.9989C5.83579 13.9989 5.5 14.3347 5.5 14.7489C5.5 15.1631 5.83579 15.4989 6.25 15.4989C8.32036 15.4989 9.99872 17.1773 9.99872 19.2476C9.99872 19.6618 10.3345 19.9976 10.7487 19.9976C11.1629 19.9976 11.4987 19.6618 11.4987 19.2476C11.4987 16.3488 9.14879 13.9989 6.25 13.9989ZM6.75 19.9991C7.44036 19.9991 8 19.4394 8 18.7491C8 18.0587 7.44036 17.4991 6.75 17.4991C6.05964 17.4991 5.5 18.0587 5.5 18.7491C5.5 19.4394 6.05964 19.9991 6.75 19.9991Z" fill="#212121"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="mdi-cast-audio" width="24" height="24" viewBox="0 0 24 24"><path d="M2 11V13C7 13 11 17 11 22H13C13 15.9 8.1 11 2 11M20 2H10C8.9 2 8 2.9 8 4V10.5C9 11 9.9 11.7 10.7 12.4C11.6 11 13.2 10 15 10C17.8 10 20 12.2 20 15S17.8 20 15 20H14.8C14.9 20.7 15 21.3 15 22H20C21.1 22 22 21.1 22 20V4C22 2.9 21.1 2 20 2M15 8C13.9 8 13 7.1 13 6C13 4.9 13.9 4 15 4C16.1 4 17 4.9 17 6S16.1 8 15 8M15 18C14.8 18 14.5 18 14.3 17.9C13.8 16.4 13.1 15.1 12.2 13.9C12.6 12.8 13.7 11.9 15 11.9C16.7 11.9 18 13.2 18 14.9S16.7 18 15 18M2 15V17C4.8 17 7 19.2 7 22H9C9 18.1 5.9 15 2 15M2 19V22H5C5 20.3 3.7 19 2 19" /></svg>

After

Width:  |  Height:  |  Size: 830 B

+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><title>DLNA icon</title><path d="M23.254,12.667H13.019c-1.051,0-2.1,0.489-2.692,1.266v-0.017c-0.591,0.816-1.544,1.353-2.629,1.353 c-1.796,0-3.249-1.458-3.249-3.251c0-1.795,1.454-3.251,3.249-3.251c1.085,0,2.039,0.536,2.629,1.353v-0.013 c0.592,0.776,1.641,1.273,2.692,1.273h10.129c0.186-0.005,0.873-0.095,0.848-0.981c-0.884-5.086-5.879-8.987-11.923-8.987 c-3.721,0-7.047,1.48-9.262,3.803c-0.356,0.527,0.014,0.689,0.35,0.734h6.608c1.05,0,2.1-0.498,2.691-1.277V4.69 c0.591-0.817,1.546-1.355,2.63-1.355c1.795,0,3.249,1.456,3.249,3.251c0,1.793-1.454,3.252-3.249,3.252 c-1.084,0-2.039-0.537-2.63-1.355v0.019c-0.591-0.779-1.641-1.266-2.691-1.266h-7.22l0.028,0.003c0,0-1.068-0.06-1.719,0.859 C0.361,8.9,0,10.621,0,12.002c0,1.388,0.198,2.649,0.867,3.923c0.564,0.908,1.71,0.849,1.71,0.849L2.535,16.78h7.233 c1.05,0,2.1-0.49,2.691-1.268v0.019c0.591-0.817,1.546-1.355,2.63-1.355c1.795,0,3.249,1.458,3.249,3.251 c0,1.795-1.454,3.251-3.249,3.251c-1.084,0-2.039-0.537-2.63-1.354v0.018c-0.591-0.779-1.641-1.277-2.691-1.277H3.164 c-0.328,0.042-0.698,0.198-0.379,0.699c2.216,2.337,5.555,3.824,9.288,3.824c6.054,0,11.056-3.911,11.926-9.009 C23.995,12.866,23.51,12.702,23.254,12.667"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:serif="http://www.serif.com/" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M12.965,5.462c0,-0 -2.584,0.004 -4.979,0.008c-3.034,0.006 -5.49,2.467 -5.49,5.5l0,13.03c0,1.459 0.579,2.858 1.611,3.889c1.031,1.032 2.43,1.611 3.889,1.611l13.003,0c3.038,-0 5.5,-2.462 5.5,-5.5c0,-2.405 0,-5.004 0,-5.004c0,-0.828 -0.672,-1.5 -1.5,-1.5c-0.827,-0 -1.5,0.672 -1.5,1.5l0,5.004c0,1.381 -1.119,2.5 -2.5,2.5l-13.003,0c-0.663,-0 -1.299,-0.263 -1.768,-0.732c-0.469,-0.469 -0.732,-1.105 -0.732,-1.768l0,-13.03c0,-1.379 1.117,-2.497 2.496,-2.5c2.394,-0.004 4.979,-0.008 4.979,-0.008c0.828,-0.002 1.498,-0.675 1.497,-1.503c-0.001,-0.828 -0.675,-1.499 -1.503,-1.497Z"/><path d="M20.046,6.411l-6.845,6.846c-0.137,0.137 -0.232,0.311 -0.271,0.501l-1.081,5.152c-0.069,0.329 0.032,0.671 0.268,0.909c0.237,0.239 0.577,0.343 0.907,0.277l5.194,-1.038c0.193,-0.039 0.371,-0.134 0.511,-0.274l6.845,-6.845l-5.528,-5.528Zm1.415,-1.414l5.527,5.528l1.112,-1.111c1.526,-1.527 1.526,-4.001 -0,-5.527c-0.001,-0 -0.001,-0.001 -0.001,-0.001c-1.527,-1.526 -4.001,-1.526 -5.527,-0l-1.111,1.111Z"/><g id="Icon"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB