Merge branch 'pre-release' into updates/uiux-1.1.7
This commit is contained in:
commit
53060ebd4d
52 files changed
+5570
-620
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::*;
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('\\', "\\\\")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
"$HOME/.cache/qbz/**",
|
||||
"$HOME/.config/qbz/**",
|
||||
"$HOME/.local/share/qbz/**",
|
||||
"$HOME/**",
|
||||
"/mnt/**",
|
||||
"/media/**",
|
||||
"/run/media/**"
|
||||
|
||||
+114
@@ -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;
|
||||
}
|
||||
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 & 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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3075,7 +3075,6 @@
|
||||
height: calc(100vh - 136px); /* 104px NowPlayingBar + 32px TitleBar */
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
Reference in new issue
Block a user