Merge branch 'pre-release' into updates/uiux-1.1.7
This commit is contained in:
commit
a533cb4a4f
49 files changed
+8979
-131
No files matched your search
@@ -33,6 +33,7 @@ base64 = "0.22"
|
||||
|
||||
# Regex (for bundle parsing)
|
||||
regex = "1"
|
||||
lazy_static = "1.4"
|
||||
|
||||
# Time
|
||||
chrono = "0.4"
|
||||
|
||||
+90
-18
@@ -195,17 +195,33 @@ impl QobuzClient {
|
||||
// === Search endpoints ===
|
||||
|
||||
/// Search for albums
|
||||
pub async fn search_albums(&self, query: &str, limit: u32, offset: u32) -> Result<SearchResultsPage<Album>> {
|
||||
/// Optional search_type: "MainArtist", "Performer", "Composer", "Label", "ReleaseName"
|
||||
pub async fn search_albums(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
search_type: Option<&str>,
|
||||
) -> Result<SearchResultsPage<Album>> {
|
||||
let url = endpoints::build_url(paths::ALBUM_SEARCH);
|
||||
let limit_str = limit.to_string();
|
||||
let offset_str = offset.to_string();
|
||||
|
||||
let mut params: Vec<(&str, &str)> = vec![
|
||||
("query", query),
|
||||
("limit", &limit_str),
|
||||
("offset", &offset_str),
|
||||
];
|
||||
|
||||
if let Some(st) = search_type {
|
||||
params.push(("type", st));
|
||||
}
|
||||
|
||||
let response: Value = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("X-App-Id", self.app_id().await?)
|
||||
.query(&[
|
||||
("query", query),
|
||||
("limit", &limit.to_string()),
|
||||
("offset", &offset.to_string()),
|
||||
])
|
||||
.query(¶ms)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
@@ -219,17 +235,33 @@ impl QobuzClient {
|
||||
}
|
||||
|
||||
/// Search for tracks
|
||||
pub async fn search_tracks(&self, query: &str, limit: u32, offset: u32) -> Result<SearchResultsPage<Track>> {
|
||||
/// Optional search_type: "MainArtist", "Performer", "Composer", "Label", "ReleaseName"
|
||||
pub async fn search_tracks(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
search_type: Option<&str>,
|
||||
) -> Result<SearchResultsPage<Track>> {
|
||||
let url = endpoints::build_url(paths::TRACK_SEARCH);
|
||||
let limit_str = limit.to_string();
|
||||
let offset_str = offset.to_string();
|
||||
|
||||
let mut params: Vec<(&str, &str)> = vec![
|
||||
("query", query),
|
||||
("limit", &limit_str),
|
||||
("offset", &offset_str),
|
||||
];
|
||||
|
||||
if let Some(st) = search_type {
|
||||
params.push(("type", st));
|
||||
}
|
||||
|
||||
let response: Value = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("X-App-Id", self.app_id().await?)
|
||||
.query(&[
|
||||
("query", query),
|
||||
("limit", &limit.to_string()),
|
||||
("offset", &offset.to_string()),
|
||||
])
|
||||
.query(¶ms)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
@@ -243,17 +275,33 @@ impl QobuzClient {
|
||||
}
|
||||
|
||||
/// Search for artists
|
||||
pub async fn search_artists(&self, query: &str, limit: u32, offset: u32) -> Result<SearchResultsPage<Artist>> {
|
||||
/// Optional search_type: "MainArtist", "Performer", "Composer", "Label", "ReleaseName"
|
||||
pub async fn search_artists(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
search_type: Option<&str>,
|
||||
) -> Result<SearchResultsPage<Artist>> {
|
||||
let url = endpoints::build_url(paths::ARTIST_SEARCH);
|
||||
let limit_str = limit.to_string();
|
||||
let offset_str = offset.to_string();
|
||||
|
||||
let mut params: Vec<(&str, &str)> = vec![
|
||||
("query", query),
|
||||
("limit", &limit_str),
|
||||
("offset", &offset_str),
|
||||
];
|
||||
|
||||
if let Some(st) = search_type {
|
||||
params.push(("type", st));
|
||||
}
|
||||
|
||||
let response: Value = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("X-App-Id", self.app_id().await?)
|
||||
.query(&[
|
||||
("query", query),
|
||||
("limit", &limit.to_string()),
|
||||
("offset", &offset.to_string()),
|
||||
])
|
||||
.query(¶ms)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
@@ -478,6 +526,30 @@ impl QobuzClient {
|
||||
Ok(serde_json::from_value(response)?)
|
||||
}
|
||||
|
||||
/// Get label by ID with albums
|
||||
pub async fn get_label(&self, label_id: u64, limit: u32, offset: u32) -> Result<LabelDetail> {
|
||||
let url = endpoints::build_url(paths::LABEL_GET);
|
||||
let locale = self.locale().await;
|
||||
|
||||
let response: Value = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("X-App-Id", self.app_id().await?)
|
||||
.query(&[
|
||||
("label_id", label_id.to_string()),
|
||||
("extra", "albums".to_string()),
|
||||
("limit", limit.to_string()),
|
||||
("offset", offset.to_string()),
|
||||
("lang", locale),
|
||||
])
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::from_value(response)?)
|
||||
}
|
||||
|
||||
// === Authenticated endpoints ===
|
||||
|
||||
/// Get stream URL for a track (requires auth + signature)
|
||||
|
||||
@@ -158,6 +158,8 @@ pub struct AlbumSummary {
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub image: ImageSet,
|
||||
/// Label (if returned in track response)
|
||||
pub label: Option<Label>,
|
||||
}
|
||||
|
||||
/// Artist model
|
||||
@@ -254,13 +256,24 @@ impl ImageSet {
|
||||
}
|
||||
}
|
||||
|
||||
/// Label model
|
||||
/// Label model (basic reference)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Label {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Label detail with albums
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LabelDetail {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub image: Option<ImageSet>,
|
||||
pub albums: Option<SearchResultsPage<Album>>,
|
||||
pub albums_count: Option<u32>,
|
||||
}
|
||||
|
||||
/// Genre model
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Genre {
|
||||
|
||||
@@ -40,6 +40,7 @@ pub struct AlbumInfo {
|
||||
pub year: String,
|
||||
pub release_date: Option<String>,
|
||||
pub label: String,
|
||||
pub label_id: Option<u64>,
|
||||
pub genre: String,
|
||||
pub quality: String,
|
||||
pub track_count: u32,
|
||||
@@ -100,6 +101,7 @@ fn album_to_info(album: &Album) -> AlbumInfo {
|
||||
year,
|
||||
release_date: album.release_date_original.clone(),
|
||||
label: album.label.as_ref().map(|l| l.name.clone()).unwrap_or_default(),
|
||||
label_id: album.label.as_ref().map(|l| l.id),
|
||||
genre: album.genre.as_ref().map(|g| g.name.clone()).unwrap_or_default(),
|
||||
quality: format_quality(album.maximum_bit_depth, album.maximum_sampling_rate),
|
||||
track_count: album.tracks_count.unwrap_or(0),
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
//! ListenBrainz Tauri commands
|
||||
//!
|
||||
//! Exposes ListenBrainz scrobbling to the frontend
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::listenbrainz::{
|
||||
AdditionalInfo, ListenBrainzSharedState, ListenBrainzStatus, QueueStats, QueuedListen, UserInfo,
|
||||
};
|
||||
|
||||
/// Get ListenBrainz connection status
|
||||
#[tauri::command]
|
||||
pub async fn listenbrainz_get_status(
|
||||
state: State<'_, ListenBrainzSharedState>,
|
||||
) -> Result<ListenBrainzStatus, String> {
|
||||
let client = state.client.lock().await;
|
||||
Ok(client.get_status().await)
|
||||
}
|
||||
|
||||
/// Check if ListenBrainz is enabled
|
||||
#[tauri::command]
|
||||
pub async fn listenbrainz_is_enabled(
|
||||
state: State<'_, ListenBrainzSharedState>,
|
||||
) -> Result<bool, String> {
|
||||
let client = state.client.lock().await;
|
||||
Ok(client.is_enabled().await)
|
||||
}
|
||||
|
||||
/// Enable or disable ListenBrainz integration
|
||||
#[tauri::command]
|
||||
pub async fn listenbrainz_set_enabled(
|
||||
enabled: bool,
|
||||
state: State<'_, ListenBrainzSharedState>,
|
||||
) -> Result<(), String> {
|
||||
// Update client
|
||||
{
|
||||
let client = state.client.lock().await;
|
||||
client.set_enabled(enabled).await;
|
||||
}
|
||||
|
||||
// Persist setting
|
||||
{
|
||||
let cache = state.cache.lock().await;
|
||||
cache.set_enabled(enabled)?;
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"ListenBrainz integration {}",
|
||||
if enabled { "enabled" } else { "disabled" }
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Connect to ListenBrainz with a user token
|
||||
#[tauri::command]
|
||||
pub async fn listenbrainz_connect(
|
||||
token: String,
|
||||
state: State<'_, ListenBrainzSharedState>,
|
||||
) -> Result<UserInfo, String> {
|
||||
log::info!("Command: listenbrainz_connect");
|
||||
|
||||
// Validate token and get user info
|
||||
let user_info = {
|
||||
let client = state.client.lock().await;
|
||||
client.set_token(&token).await?
|
||||
};
|
||||
|
||||
// Persist credentials
|
||||
{
|
||||
let cache = state.cache.lock().await;
|
||||
cache.set_credentials(Some(&token), Some(&user_info.user_name))?;
|
||||
}
|
||||
|
||||
Ok(user_info)
|
||||
}
|
||||
|
||||
/// Disconnect from ListenBrainz
|
||||
#[tauri::command]
|
||||
pub async fn listenbrainz_disconnect(
|
||||
state: State<'_, ListenBrainzSharedState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: listenbrainz_disconnect");
|
||||
|
||||
// Clear client state
|
||||
{
|
||||
let client = state.client.lock().await;
|
||||
client.disconnect().await;
|
||||
}
|
||||
|
||||
// Clear persisted credentials
|
||||
{
|
||||
let cache = state.cache.lock().await;
|
||||
cache.clear_credentials()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Submit "now playing" to ListenBrainz
|
||||
#[tauri::command]
|
||||
pub async fn listenbrainz_now_playing(
|
||||
artist: String,
|
||||
track: String,
|
||||
album: Option<String>,
|
||||
recording_mbid: Option<String>,
|
||||
release_mbid: Option<String>,
|
||||
artist_mbids: Option<Vec<String>>,
|
||||
isrc: Option<String>,
|
||||
duration_ms: Option<u64>,
|
||||
state: State<'_, ListenBrainzSharedState>,
|
||||
) -> Result<(), String> {
|
||||
log::debug!("Command: listenbrainz_now_playing - {} - {}", artist, track);
|
||||
|
||||
let client = state.client.lock().await;
|
||||
|
||||
// Build additional info with MusicBrainz data
|
||||
let mut info = AdditionalInfo::new();
|
||||
info.recording_mbid = recording_mbid;
|
||||
info.release_mbid = release_mbid;
|
||||
info.artist_mbids = artist_mbids;
|
||||
info.isrc = isrc;
|
||||
info.duration_ms = duration_ms;
|
||||
|
||||
client
|
||||
.submit_playing_now(&artist, &track, album.as_deref(), Some(info))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Submit a scrobble to ListenBrainz
|
||||
#[tauri::command]
|
||||
pub async fn listenbrainz_scrobble(
|
||||
artist: String,
|
||||
track: String,
|
||||
album: Option<String>,
|
||||
timestamp: i64,
|
||||
recording_mbid: Option<String>,
|
||||
release_mbid: Option<String>,
|
||||
artist_mbids: Option<Vec<String>>,
|
||||
isrc: Option<String>,
|
||||
duration_ms: Option<u64>,
|
||||
state: State<'_, ListenBrainzSharedState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: listenbrainz_scrobble - {} - {}", artist, track);
|
||||
|
||||
let client = state.client.lock().await;
|
||||
|
||||
// Build additional info with MusicBrainz data
|
||||
let mut info = AdditionalInfo::new();
|
||||
info.recording_mbid = recording_mbid;
|
||||
info.release_mbid = release_mbid;
|
||||
info.artist_mbids = artist_mbids;
|
||||
info.isrc = isrc;
|
||||
info.duration_ms = duration_ms;
|
||||
|
||||
client
|
||||
.submit_listen(&artist, &track, album.as_deref(), timestamp, Some(info))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Queue a listen for offline submission
|
||||
#[tauri::command]
|
||||
pub async fn listenbrainz_queue_listen(
|
||||
artist: String,
|
||||
track: String,
|
||||
album: Option<String>,
|
||||
timestamp: i64,
|
||||
recording_mbid: Option<String>,
|
||||
release_mbid: Option<String>,
|
||||
artist_mbids: Option<Vec<String>>,
|
||||
isrc: Option<String>,
|
||||
duration_ms: Option<u64>,
|
||||
state: State<'_, ListenBrainzSharedState>,
|
||||
) -> Result<i64, String> {
|
||||
log::info!("Command: listenbrainz_queue_listen - {} - {}", artist, track);
|
||||
|
||||
let cache = state.cache.lock().await;
|
||||
cache.queue_listen(
|
||||
timestamp,
|
||||
&artist,
|
||||
&track,
|
||||
album.as_deref(),
|
||||
recording_mbid.as_deref(),
|
||||
release_mbid.as_deref(),
|
||||
artist_mbids.as_deref(),
|
||||
isrc.as_deref(),
|
||||
duration_ms,
|
||||
)
|
||||
}
|
||||
|
||||
/// Get queued listens (for sync)
|
||||
#[tauri::command]
|
||||
pub async fn listenbrainz_get_queue(
|
||||
limit: Option<u32>,
|
||||
state: State<'_, ListenBrainzSharedState>,
|
||||
) -> Result<Vec<QueuedListen>, String> {
|
||||
let cache = state.cache.lock().await;
|
||||
cache.get_queued_listens(limit.unwrap_or(50))
|
||||
}
|
||||
|
||||
/// Get queue count
|
||||
#[tauri::command]
|
||||
pub async fn listenbrainz_get_queue_count(
|
||||
state: State<'_, ListenBrainzSharedState>,
|
||||
) -> Result<u32, String> {
|
||||
let cache = state.cache.lock().await;
|
||||
cache.get_queue_count()
|
||||
}
|
||||
|
||||
/// Mark queued listens as sent
|
||||
#[tauri::command]
|
||||
pub async fn listenbrainz_mark_sent(
|
||||
ids: Vec<i64>,
|
||||
state: State<'_, ListenBrainzSharedState>,
|
||||
) -> Result<(), String> {
|
||||
let cache = state.cache.lock().await;
|
||||
cache.mark_listens_sent(&ids)
|
||||
}
|
||||
|
||||
/// Flush queue (submit all pending listens)
|
||||
#[tauri::command]
|
||||
pub async fn listenbrainz_flush_queue(
|
||||
state: State<'_, ListenBrainzSharedState>,
|
||||
) -> Result<u32, String> {
|
||||
log::info!("Command: listenbrainz_flush_queue");
|
||||
|
||||
// Get pending listens
|
||||
let pending = {
|
||||
let cache = state.cache.lock().await;
|
||||
cache.get_queued_listens(50)?
|
||||
};
|
||||
|
||||
if pending.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let client = state.client.lock().await;
|
||||
let mut sent_ids = Vec::new();
|
||||
|
||||
for listen in &pending {
|
||||
// Build additional info
|
||||
let mut info = AdditionalInfo::new();
|
||||
info.recording_mbid = listen.recording_mbid.clone();
|
||||
info.release_mbid = listen.release_mbid.clone();
|
||||
info.artist_mbids = listen.artist_mbids.clone();
|
||||
info.isrc = listen.isrc.clone();
|
||||
info.duration_ms = listen.duration_ms;
|
||||
|
||||
match client
|
||||
.submit_listen(
|
||||
&listen.artist_name,
|
||||
&listen.track_name,
|
||||
listen.release_name.as_deref(),
|
||||
listen.listened_at,
|
||||
Some(info),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
sent_ids.push(listen.id);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to submit queued listen {}: {}", listen.id, e);
|
||||
// Increment attempt count
|
||||
let cache = state.cache.lock().await;
|
||||
let _ = cache.increment_attempts(listen.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark successful ones as sent
|
||||
if !sent_ids.is_empty() {
|
||||
let cache = state.cache.lock().await;
|
||||
cache.mark_listens_sent(&sent_ids)?;
|
||||
}
|
||||
|
||||
let sent_count = sent_ids.len() as u32;
|
||||
log::info!("ListenBrainz queue flush: sent {}/{} listens", sent_count, pending.len());
|
||||
|
||||
Ok(sent_count)
|
||||
}
|
||||
|
||||
/// Clear the offline queue
|
||||
#[tauri::command]
|
||||
pub async fn listenbrainz_clear_queue(
|
||||
state: State<'_, ListenBrainzSharedState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: listenbrainz_clear_queue");
|
||||
let cache = state.cache.lock().await;
|
||||
cache.clear_queue()
|
||||
}
|
||||
|
||||
/// Cleanup old sent listens
|
||||
#[tauri::command]
|
||||
pub async fn listenbrainz_cleanup_queue(
|
||||
older_than_days: Option<u32>,
|
||||
state: State<'_, ListenBrainzSharedState>,
|
||||
) -> Result<u32, String> {
|
||||
let cache = state.cache.lock().await;
|
||||
cache.cleanup_sent_listens(older_than_days.unwrap_or(7))
|
||||
}
|
||||
@@ -9,6 +9,9 @@ pub mod cache;
|
||||
pub mod credits;
|
||||
pub mod favorites;
|
||||
pub mod lastfm;
|
||||
pub mod listenbrainz;
|
||||
pub mod musician;
|
||||
pub mod musicbrainz;
|
||||
pub mod notification;
|
||||
pub mod playback;
|
||||
pub mod playback_context;
|
||||
@@ -18,6 +21,7 @@ pub mod queue;
|
||||
pub mod radio;
|
||||
pub mod search;
|
||||
pub mod share;
|
||||
pub mod smart_playlists;
|
||||
|
||||
pub use audio_backends::*;
|
||||
pub use audio_diagnostics::*;
|
||||
@@ -26,6 +30,9 @@ pub use cache::*;
|
||||
pub use credits::*;
|
||||
pub use favorites::*;
|
||||
pub use lastfm::*;
|
||||
pub use listenbrainz::*;
|
||||
pub use musician::*;
|
||||
pub use musicbrainz::*;
|
||||
pub use notification::*;
|
||||
pub use playback::*;
|
||||
pub use playback_context::*;
|
||||
@@ -35,3 +42,4 @@ pub use queue::*;
|
||||
pub use radio::*;
|
||||
pub use search::*;
|
||||
pub use share::*;
|
||||
pub use smart_playlists::*;
|
||||
@@ -0,0 +1,433 @@
|
||||
//! MusicBrainz Tauri commands
|
||||
//!
|
||||
//! Exposes MusicBrainz entity resolution to the frontend
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::musicbrainz::{
|
||||
ArtistRelationships, CacheStats, MatchConfidence, MusicBrainzSharedState, ResolvedArtist,
|
||||
ResolvedRelease, ResolvedTrack,
|
||||
};
|
||||
|
||||
/// Resolve a track to MusicBrainz by ISRC
|
||||
#[tauri::command]
|
||||
pub async fn musicbrainz_resolve_track(
|
||||
isrc: Option<String>,
|
||||
title: String,
|
||||
artist: String,
|
||||
state: State<'_, MusicBrainzSharedState>,
|
||||
) -> Result<ResolvedTrack, String> {
|
||||
if !state.client.is_enabled().await {
|
||||
return Ok(ResolvedTrack::empty());
|
||||
}
|
||||
|
||||
// Check cache first (short lock)
|
||||
if let Some(ref isrc_val) = isrc {
|
||||
let cached = {
|
||||
let cache = state.cache.lock().await;
|
||||
cache.get_recording(isrc_val).ok().flatten()
|
||||
};
|
||||
if let Some(cached) = cached {
|
||||
log::debug!("MusicBrainz cache hit for ISRC: {}", isrc_val);
|
||||
return Ok(cached);
|
||||
}
|
||||
}
|
||||
|
||||
// Try ISRC lookup
|
||||
if let Some(ref isrc_val) = isrc {
|
||||
match state.client.search_recording_by_isrc(isrc_val).await {
|
||||
Ok(response) => {
|
||||
if let Some(recording) = response.recordings.first() {
|
||||
let resolved = recording_to_resolved(recording);
|
||||
// Cache the result (short lock)
|
||||
{
|
||||
let cache = state.cache.lock().await;
|
||||
let _ = cache.set_recording(isrc_val, &resolved);
|
||||
}
|
||||
return Ok(resolved);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("MusicBrainz ISRC lookup failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to title+artist search
|
||||
match state.client.search_recording(&title, &artist).await {
|
||||
Ok(response) => {
|
||||
if let Some(recording) = response
|
||||
.recordings
|
||||
.iter()
|
||||
.find(|r| r.score.unwrap_or(0) >= 80)
|
||||
{
|
||||
let resolved = recording_to_resolved(recording);
|
||||
// Cache by ISRC if available
|
||||
if let Some(ref isrc_val) = isrc {
|
||||
let cache = state.cache.lock().await;
|
||||
let _ = cache.set_recording(isrc_val, &resolved);
|
||||
}
|
||||
return Ok(resolved);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("MusicBrainz recording search failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache negative result
|
||||
if let Some(ref isrc_val) = isrc {
|
||||
let empty = ResolvedTrack::empty();
|
||||
let cache = state.cache.lock().await;
|
||||
let _ = cache.set_recording(isrc_val, &empty);
|
||||
}
|
||||
|
||||
Ok(ResolvedTrack::empty())
|
||||
}
|
||||
|
||||
/// Resolve an artist to MusicBrainz by name
|
||||
#[tauri::command]
|
||||
pub async fn musicbrainz_resolve_artist(
|
||||
name: String,
|
||||
state: State<'_, MusicBrainzSharedState>,
|
||||
) -> Result<ResolvedArtist, String> {
|
||||
if !state.client.is_enabled().await {
|
||||
return Ok(ResolvedArtist::empty());
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
let cached = {
|
||||
let cache = state.cache.lock().await;
|
||||
cache.get_artist(&name).ok().flatten()
|
||||
};
|
||||
if let Some(cached) = cached {
|
||||
log::debug!("MusicBrainz cache hit for artist: {}", name);
|
||||
return Ok(cached);
|
||||
}
|
||||
|
||||
match state.client.search_artist(&name).await {
|
||||
Ok(response) => {
|
||||
if let Some(artist) = response
|
||||
.artists
|
||||
.iter()
|
||||
.find(|a| a.score.unwrap_or(0) >= 90)
|
||||
{
|
||||
let resolved = artist_to_resolved(artist);
|
||||
// Cache the result
|
||||
{
|
||||
let cache = state.cache.lock().await;
|
||||
let _ = cache.set_artist(&name, &resolved);
|
||||
}
|
||||
return Ok(resolved);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("MusicBrainz artist search failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache negative result
|
||||
let empty = ResolvedArtist::empty();
|
||||
{
|
||||
let cache = state.cache.lock().await;
|
||||
let _ = cache.set_artist(&name, &empty);
|
||||
}
|
||||
|
||||
Ok(empty)
|
||||
}
|
||||
|
||||
/// Resolve a release to MusicBrainz by UPC/barcode
|
||||
#[tauri::command]
|
||||
pub async fn musicbrainz_resolve_release(
|
||||
upc: Option<String>,
|
||||
title: String,
|
||||
artist: String,
|
||||
state: State<'_, MusicBrainzSharedState>,
|
||||
) -> Result<ResolvedRelease, String> {
|
||||
if !state.client.is_enabled().await {
|
||||
return Ok(ResolvedRelease::empty());
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if let Some(ref upc_val) = upc {
|
||||
let cached = {
|
||||
let cache = state.cache.lock().await;
|
||||
cache.get_release(upc_val).ok().flatten()
|
||||
};
|
||||
if let Some(cached) = cached {
|
||||
log::debug!("MusicBrainz cache hit for UPC: {}", upc_val);
|
||||
return Ok(cached);
|
||||
}
|
||||
}
|
||||
|
||||
// Try barcode lookup
|
||||
if let Some(ref upc_val) = upc {
|
||||
match state.client.search_release_by_barcode(upc_val).await {
|
||||
Ok(response) => {
|
||||
if let Some(release) = response.releases.first() {
|
||||
let resolved = release_to_resolved(release);
|
||||
// Cache the result
|
||||
{
|
||||
let cache = state.cache.lock().await;
|
||||
let _ = cache.set_release(upc_val, &resolved);
|
||||
}
|
||||
return Ok(resolved);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("MusicBrainz barcode lookup failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to title+artist search
|
||||
match state.client.search_release(&title, &artist).await {
|
||||
Ok(response) => {
|
||||
if let Some(release) = response
|
||||
.releases
|
||||
.iter()
|
||||
.find(|r| r.score.unwrap_or(0) >= 80)
|
||||
{
|
||||
let resolved = release_to_resolved(release);
|
||||
// Cache by UPC if available
|
||||
if let Some(ref upc_val) = upc {
|
||||
let cache = state.cache.lock().await;
|
||||
let _ = cache.set_release(upc_val, &resolved);
|
||||
}
|
||||
return Ok(resolved);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("MusicBrainz release search failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache negative result
|
||||
if let Some(ref upc_val) = upc {
|
||||
let empty = ResolvedRelease::empty();
|
||||
let cache = state.cache.lock().await;
|
||||
let _ = cache.set_release(upc_val, &empty);
|
||||
}
|
||||
|
||||
Ok(ResolvedRelease::empty())
|
||||
}
|
||||
|
||||
/// Get artist relationships by MBID (for Stage 3)
|
||||
#[tauri::command]
|
||||
pub async fn musicbrainz_get_artist_relationships(
|
||||
mbid: String,
|
||||
state: State<'_, MusicBrainzSharedState>,
|
||||
) -> Result<ArtistRelationships, String> {
|
||||
if !state.client.is_enabled().await {
|
||||
return Ok(ArtistRelationships::empty());
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
let cached = {
|
||||
let cache = state.cache.lock().await;
|
||||
cache.get_artist_relations(&mbid).ok().flatten()
|
||||
};
|
||||
if let Some(cached) = cached {
|
||||
log::debug!("MusicBrainz cache hit for artist relations: {}", mbid);
|
||||
return Ok(cached);
|
||||
}
|
||||
|
||||
match state.client.get_artist_with_relations(&mbid).await {
|
||||
Ok(response) => {
|
||||
let relations = extract_relationships(&response);
|
||||
// Cache the result
|
||||
{
|
||||
let cache = state.cache.lock().await;
|
||||
let _ = cache.set_artist_relations(&mbid, &relations);
|
||||
}
|
||||
Ok(relations)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("MusicBrainz artist relations lookup failed: {}", e);
|
||||
// Cache negative result
|
||||
let empty = ArtistRelationships::empty();
|
||||
{
|
||||
let cache = state.cache.lock().await;
|
||||
let _ = cache.set_artist_relations(&mbid, &empty);
|
||||
}
|
||||
Ok(empty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if MusicBrainz integration is enabled
|
||||
#[tauri::command]
|
||||
pub async fn musicbrainz_is_enabled(
|
||||
state: State<'_, MusicBrainzSharedState>,
|
||||
) -> Result<bool, String> {
|
||||
Ok(state.client.is_enabled().await)
|
||||
}
|
||||
|
||||
/// Enable or disable MusicBrainz integration
|
||||
#[tauri::command]
|
||||
pub async fn musicbrainz_set_enabled(
|
||||
enabled: bool,
|
||||
state: State<'_, MusicBrainzSharedState>,
|
||||
) -> Result<(), String> {
|
||||
state.client.set_enabled(enabled).await;
|
||||
log::info!(
|
||||
"MusicBrainz integration {}",
|
||||
if enabled { "enabled" } else { "disabled" }
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get MusicBrainz cache statistics
|
||||
#[tauri::command]
|
||||
pub async fn musicbrainz_get_cache_stats(
|
||||
state: State<'_, MusicBrainzSharedState>,
|
||||
) -> Result<CacheStats, String> {
|
||||
let cache = state.cache.lock().await;
|
||||
cache.get_stats()
|
||||
}
|
||||
|
||||
/// Clear MusicBrainz cache
|
||||
#[tauri::command]
|
||||
pub async fn musicbrainz_clear_cache(
|
||||
state: State<'_, MusicBrainzSharedState>,
|
||||
) -> Result<(), String> {
|
||||
let cache = state.cache.lock().await;
|
||||
cache.clear_all()
|
||||
}
|
||||
|
||||
/// Cleanup expired cache entries
|
||||
#[tauri::command]
|
||||
pub async fn musicbrainz_cleanup_cache(
|
||||
state: State<'_, MusicBrainzSharedState>,
|
||||
) -> Result<usize, String> {
|
||||
let cache = state.cache.lock().await;
|
||||
cache.cleanup_expired()
|
||||
}
|
||||
|
||||
// ============ Helper functions ============
|
||||
|
||||
use crate::musicbrainz::models::{
|
||||
ArtistFullResponse, ArtistResult, RecordingResult, RelatedArtist, ReleaseResult, Period,
|
||||
};
|
||||
|
||||
fn recording_to_resolved(recording: &RecordingResult) -> ResolvedTrack {
|
||||
let artist_credit = recording.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("")
|
||||
});
|
||||
|
||||
let artist_mbids = recording
|
||||
.artist_credit
|
||||
.as_ref()
|
||||
.map(|credits| credits.iter().map(|c| c.artist.id.clone()).collect());
|
||||
|
||||
let (release_mbid, release_title) = recording
|
||||
.releases
|
||||
.as_ref()
|
||||
.and_then(|releases| releases.first())
|
||||
.map(|r| (Some(r.id.clone()), r.title.clone()))
|
||||
.unwrap_or((None, None));
|
||||
|
||||
ResolvedTrack {
|
||||
mbid: Some(recording.id.clone()),
|
||||
title: recording.title.clone(),
|
||||
artist_credit,
|
||||
artist_mbids,
|
||||
release_mbid,
|
||||
release_title,
|
||||
confidence: MatchConfidence::from_score(recording.score),
|
||||
}
|
||||
}
|
||||
|
||||
fn artist_to_resolved(artist: &ArtistResult) -> ResolvedArtist {
|
||||
use crate::musicbrainz::ArtistType;
|
||||
|
||||
ResolvedArtist {
|
||||
mbid: Some(artist.id.clone()),
|
||||
name: Some(artist.name.clone()),
|
||||
sort_name: artist.sort_name.clone(),
|
||||
artist_type: Some(ArtistType::from(artist.artist_type.as_deref())),
|
||||
country: artist.country.clone(),
|
||||
disambiguation: artist.disambiguation.clone(),
|
||||
confidence: MatchConfidence::from_score(artist.score),
|
||||
}
|
||||
}
|
||||
|
||||
fn release_to_resolved(release: &ReleaseResult) -> ResolvedRelease {
|
||||
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));
|
||||
|
||||
ResolvedRelease {
|
||||
mbid: Some(release.id.clone()),
|
||||
title: Some(release.title.clone()),
|
||||
release_group_mbid: release.release_group.as_ref().map(|rg| rg.id.clone()),
|
||||
label,
|
||||
catalog_number,
|
||||
country: release.country.clone(),
|
||||
date: release.date.clone(),
|
||||
confidence: MatchConfidence::from_score(release.score),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_relationships(artist: &ArtistFullResponse) -> ArtistRelationships {
|
||||
let mut members = Vec::new();
|
||||
let mut past_members = Vec::new();
|
||||
let mut groups = Vec::new();
|
||||
let mut collaborators = Vec::new();
|
||||
|
||||
if let Some(relations) = &artist.relations {
|
||||
for relation in relations {
|
||||
let Some(related_artist) = &relation.artist else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let related = RelatedArtist {
|
||||
mbid: related_artist.id.clone(),
|
||||
name: related_artist.name.clone(),
|
||||
role: relation.attributes.as_ref().and_then(|a| a.first().cloned()),
|
||||
period: Some(Period {
|
||||
begin: relation.begin.clone(),
|
||||
end: relation.end.clone(),
|
||||
}),
|
||||
ended: relation.ended.unwrap_or(false),
|
||||
};
|
||||
|
||||
match relation.relation_type.as_str() {
|
||||
"member of band" => {
|
||||
if relation.direction.as_deref() == Some("backward") {
|
||||
// We're viewing a BAND, the related artist is a MEMBER
|
||||
// All members go to the same list - don't separate by ended status
|
||||
members.push(related);
|
||||
} else {
|
||||
// We're viewing a PERSON, the related artist is a BAND/GROUP
|
||||
groups.push(related);
|
||||
}
|
||||
}
|
||||
"collaboration" => {
|
||||
collaborators.push(related);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ArtistRelationships {
|
||||
members,
|
||||
past_members,
|
||||
groups,
|
||||
collaborators,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
//! Musician resolution commands
|
||||
//!
|
||||
//! Handles musician discovery and confidence assessment for the
|
||||
//! Musician Page and Informational Modal features.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use tauri::State;
|
||||
|
||||
// Note: We use Qobuz search APIs which return these types internally
|
||||
use crate::musicbrainz::{
|
||||
AlbumAppearance, MusicBrainzSharedState, MusicianAppearances, MusicianConfidence,
|
||||
ResolvedMusician,
|
||||
};
|
||||
use crate::AppState;
|
||||
|
||||
/// Resolve a musician from credits to determine navigation destination
|
||||
///
|
||||
/// Returns a ResolvedMusician with:
|
||||
/// - Confidence level (determines UI: Artist Page, Musician Page, or Modal)
|
||||
/// - Qobuz artist ID if found (for navigation to Artist Page)
|
||||
/// - MusicBrainz data if available (bands, MBID)
|
||||
/// - Appears-on count from Qobuz
|
||||
#[tauri::command]
|
||||
pub async fn resolve_musician(
|
||||
name: String,
|
||||
role: String,
|
||||
state: State<'_, AppState>,
|
||||
mb_state: State<'_, MusicBrainzSharedState>,
|
||||
) -> Result<ResolvedMusician, String> {
|
||||
log::info!("Resolving musician: {} (role: {})", name, role);
|
||||
|
||||
let mut musician = ResolvedMusician::empty(name.clone(), role.clone());
|
||||
|
||||
// Step 1: Search Qobuz for artist, handling "The" prefix variations
|
||||
// MusicBrainz often has "Beatles" but Qobuz has "The Beatles"
|
||||
let qobuz_artist = {
|
||||
let client = state.client.lock().await;
|
||||
let name_lower = name.to_lowercase();
|
||||
|
||||
// Search with original name
|
||||
let mut best_match: Option<crate::api::Artist> = None;
|
||||
|
||||
if let Ok(results) = client.search_artists(&name, 10, 0, None).await {
|
||||
// Look for exact match first
|
||||
for artist in &results.items {
|
||||
let artist_lower = artist.name.to_lowercase();
|
||||
if artist_lower == name_lower {
|
||||
best_match = Some(artist.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no exact match, check for "The X" variant
|
||||
if best_match.is_none() {
|
||||
let the_name = format!("the {}", name_lower);
|
||||
for artist in &results.items {
|
||||
let artist_lower = artist.name.to_lowercase();
|
||||
if artist_lower == the_name {
|
||||
log::info!("Found 'The' variant: {} -> {}", name, artist.name);
|
||||
best_match = Some(artist.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always check if "The X" variant has more STUDIO albums
|
||||
// Total album count is misleading (tributes, compilations inflate it)
|
||||
if let Some(ref current) = best_match {
|
||||
// Count studio albums for current match
|
||||
let current_studio = count_studio_albums(&client, current.id).await;
|
||||
log::info!(
|
||||
"Match '{}' has {} studio albums (total: {})",
|
||||
current.name, current_studio, current.albums_count.unwrap_or(0)
|
||||
);
|
||||
|
||||
// If few studio albums, check "The X" variant
|
||||
if current_studio < 10 {
|
||||
let the_name = format!("The {}", name);
|
||||
if let Ok(the_results) = client.search_artists(&the_name, 5, 0, None).await {
|
||||
for artist in the_results.items {
|
||||
let artist_lower = artist.name.to_lowercase();
|
||||
let the_name_lower = the_name.to_lowercase();
|
||||
if artist_lower == the_name_lower {
|
||||
let the_studio = count_studio_albums(&client, artist.id).await;
|
||||
|
||||
log::info!(
|
||||
"Comparing studio albums: '{}' has {} studio, 'The {}' has {} studio",
|
||||
current.name, current_studio, name, the_studio
|
||||
);
|
||||
|
||||
if the_studio > current_studio {
|
||||
log::info!(
|
||||
"Preferring 'The {}' ({} studio albums) over '{}' ({} studio albums)",
|
||||
name, the_studio, current.name, current_studio
|
||||
);
|
||||
best_match = Some(artist);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
best_match
|
||||
};
|
||||
|
||||
// Step 2: If found, check if they have a real catalog
|
||||
if let Some(artist) = qobuz_artist {
|
||||
let has_catalog = artist.albums_count.unwrap_or(0) > 0;
|
||||
|
||||
if has_catalog {
|
||||
// Confidence = 3: Has a dedicated Qobuz artist page
|
||||
musician.qobuz_artist_id = Some(artist.id as i64);
|
||||
musician.confidence = MusicianConfidence::Confirmed;
|
||||
log::info!(
|
||||
"Musician {} resolved as Confirmed (Qobuz artist ID: {}, name: {})",
|
||||
name,
|
||||
artist.id,
|
||||
artist.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: If no artist page, search for appearances in track credits
|
||||
if musician.confidence == MusicianConfidence::None {
|
||||
let appearances_count = count_track_appearances(&state, &name).await;
|
||||
musician.appears_on_count = appearances_count;
|
||||
|
||||
if appearances_count >= 3 {
|
||||
// Confidence = 2: Appears in multiple album credits
|
||||
musician.confidence = MusicianConfidence::Contextual;
|
||||
log::info!(
|
||||
"Musician {} resolved as Contextual ({} appearances)",
|
||||
name,
|
||||
appearances_count
|
||||
);
|
||||
} else if appearances_count > 0 {
|
||||
// Confidence = 1: Sparse results
|
||||
musician.confidence = MusicianConfidence::Weak;
|
||||
log::info!(
|
||||
"Musician {} resolved as Weak ({} appearances)",
|
||||
name,
|
||||
appearances_count
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Try MusicBrainz enrichment (if enabled)
|
||||
if mb_state.client.is_enabled().await {
|
||||
match enrich_from_musicbrainz(&mb_state, &name).await {
|
||||
Ok((mbid, bands)) => {
|
||||
musician.mbid = mbid;
|
||||
musician.bands = bands;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("MusicBrainz enrichment failed for {}: {}", name, e);
|
||||
// Continue without MB data - don't block
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Musician {} final confidence: {:?}",
|
||||
name,
|
||||
musician.confidence
|
||||
);
|
||||
Ok(musician)
|
||||
}
|
||||
|
||||
/// Get albums a musician appears on (Qobuz only)
|
||||
///
|
||||
/// Returns deduplicated album appearances sorted by year.
|
||||
/// This is the data source for the "Appears On" section of the Musician Page.
|
||||
#[tauri::command]
|
||||
pub async fn get_musician_appearances(
|
||||
name: String,
|
||||
_role: Option<String>,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<MusicianAppearances, String> {
|
||||
log::info!(
|
||||
"Getting musician appearances: {} (limit: {}, offset: {})",
|
||||
name,
|
||||
limit,
|
||||
offset
|
||||
);
|
||||
|
||||
// Search for tracks by performer name
|
||||
let tracks = {
|
||||
let client = state.client.lock().await;
|
||||
client
|
||||
.search_tracks(&name, 100, 0, None)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
// Deduplicate by album, keeping track of roles
|
||||
let mut seen_albums = HashSet::new();
|
||||
let mut albums: Vec<AlbumAppearance> = Vec::new();
|
||||
|
||||
for track in tracks.items {
|
||||
// Check if performer matches
|
||||
let performer_name = track
|
||||
.performer
|
||||
.as_ref()
|
||||
.map(|p| p.name.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Also check the performers string for session musicians
|
||||
let performers_str = track.performers.as_deref().unwrap_or("");
|
||||
let name_lower = name.to_lowercase();
|
||||
|
||||
let matches = performer_name.to_lowercase() == name_lower
|
||||
|| performers_str.to_lowercase().contains(&name_lower);
|
||||
|
||||
if !matches {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(album) = &track.album {
|
||||
if seen_albums.contains(&album.id) {
|
||||
continue;
|
||||
}
|
||||
seen_albums.insert(album.id.clone());
|
||||
|
||||
// Extract role from performers string if possible
|
||||
let role = extract_role_from_performers(performers_str, &name);
|
||||
|
||||
albums.push(AlbumAppearance {
|
||||
album_id: album.id.clone(),
|
||||
album_title: album.title.clone(),
|
||||
album_artwork: album
|
||||
.image
|
||||
.large
|
||||
.clone()
|
||||
.or_else(|| album.image.thumbnail.clone())
|
||||
.unwrap_or_default(),
|
||||
// AlbumSummary doesn't have artist field, use track's performer
|
||||
artist_name: track
|
||||
.performer
|
||||
.as_ref()
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_else(|| performer_name.to_string()),
|
||||
// AlbumSummary doesn't have release_date_original
|
||||
year: None,
|
||||
role_on_album: role,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by year descending
|
||||
albums.sort_by(|a, b| {
|
||||
let year_a = a.year.as_deref().unwrap_or("0");
|
||||
let year_b = b.year.as_deref().unwrap_or("0");
|
||||
year_b.cmp(year_a)
|
||||
});
|
||||
|
||||
let total = albums.len();
|
||||
|
||||
// Apply pagination
|
||||
let start = offset as usize;
|
||||
let end = (start + limit as usize).min(albums.len());
|
||||
let paginated = if start < albums.len() {
|
||||
albums[start..end].to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(MusicianAppearances {
|
||||
albums: paginated,
|
||||
total,
|
||||
})
|
||||
}
|
||||
|
||||
// ============ Helper Functions ============
|
||||
|
||||
/// Count how many unique albums a musician appears on
|
||||
async fn count_track_appearances(state: &State<'_, AppState>, name: &str) -> usize {
|
||||
let client = state.client.lock().await;
|
||||
|
||||
match client.search_tracks(name, 50, 0, None).await {
|
||||
Ok(results) => {
|
||||
let mut seen_albums = HashSet::new();
|
||||
let name_lower = name.to_lowercase();
|
||||
|
||||
for track in results.items {
|
||||
// Check performer name
|
||||
let performer_name = track
|
||||
.performer
|
||||
.as_ref()
|
||||
.map(|p| p.name.to_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Check performers string
|
||||
let performers_str = track
|
||||
.performers
|
||||
.as_ref()
|
||||
.map(|s| s.to_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
let matches =
|
||||
performer_name == name_lower || performers_str.contains(&name_lower);
|
||||
|
||||
if matches {
|
||||
if let Some(album) = &track.album {
|
||||
seen_albums.insert(album.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
seen_albums.len()
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to count appearances for {}: {}", name, e);
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enrich musician data from MusicBrainz
|
||||
async fn enrich_from_musicbrainz(
|
||||
mb_state: &State<'_, MusicBrainzSharedState>,
|
||||
name: &str,
|
||||
) -> Result<(Option<String>, Vec<String>), String> {
|
||||
// Resolve artist to get MBID
|
||||
let resolved = {
|
||||
let cache = mb_state.cache.lock().await;
|
||||
cache.get_artist(name).ok().flatten()
|
||||
};
|
||||
|
||||
let mbid = if let Some(cached) = resolved {
|
||||
cached.mbid
|
||||
} else {
|
||||
// Search MusicBrainz for the artist
|
||||
match mb_state.client.search_artist(name).await {
|
||||
Ok(response) => {
|
||||
// Find best match (score >= 90)
|
||||
response
|
||||
.artists
|
||||
.into_iter()
|
||||
.find(|a| a.score.unwrap_or(0) >= 90)
|
||||
.map(|a| a.id)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("MusicBrainz artist search failed: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut bands = Vec::new();
|
||||
|
||||
// If we have an MBID, get relationships to find bands
|
||||
if let Some(ref mbid) = mbid {
|
||||
match mb_state.client.get_artist_with_relations(mbid).await {
|
||||
Ok(response) => {
|
||||
if let Some(relations) = response.relations {
|
||||
for relation in relations {
|
||||
// "member of band" relation with forward direction means this person is in a band
|
||||
if relation.relation_type == "member of band"
|
||||
&& relation.direction.as_deref() != Some("backward")
|
||||
{
|
||||
if let Some(artist) = relation.artist {
|
||||
bands.push(artist.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("MusicBrainz relations fetch failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((mbid, bands))
|
||||
}
|
||||
|
||||
/// Extract role from performers string
|
||||
/// e.g., "John Smith, Piano - Jane Doe, Vocals" -> "Piano" for "John Smith"
|
||||
fn extract_role_from_performers(performers: &str, name: &str) -> String {
|
||||
let name_lower = name.to_lowercase();
|
||||
|
||||
// Split by common delimiters
|
||||
for segment in performers.split(&['-', '–', '|', ';'][..]) {
|
||||
let segment = segment.trim();
|
||||
|
||||
// Check if this segment contains the name
|
||||
if segment.to_lowercase().contains(&name_lower) {
|
||||
// Try to extract role after comma
|
||||
if let Some(comma_pos) = segment.find(',') {
|
||||
let after_comma = segment[comma_pos + 1..].trim();
|
||||
// Clean up - take first word or phrase before next punctuation
|
||||
let role = after_comma
|
||||
.split(&[',', '(', ')'][..])
|
||||
.next()
|
||||
.unwrap_or(after_comma)
|
||||
.trim();
|
||||
if !role.is_empty() {
|
||||
return role.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
"Performer".to_string()
|
||||
}
|
||||
|
||||
// ============ Album Categorization (mirrors qobuzAdapters.ts logic) ============
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
|
||||
lazy_static! {
|
||||
// Unofficial releases patterns
|
||||
static ref BROADCAST_PATTERNS: Regex = Regex::new(r"(?i)\b(fm broadcasts?|radio broadcasts?|broadcasts?|bootleg|unofficial|pirate)\b").unwrap();
|
||||
static ref NON_MUSIC_PATTERNS: Regex = Regex::new(r"(?i)\b(interviews?|speaks|talking|in their own words|the interviews?|memories:?\s*the)\b").unwrap();
|
||||
static ref BOOTLEG_LABELS: Regex = Regex::new(r"(?i)\b(leftfield media|purple pyramid|cleopatra|laser media|radio lu|broadcast archives)\b").unwrap();
|
||||
|
||||
// Compilation patterns
|
||||
static ref COMPILATION_PATTERNS: Regex = Regex::new(r"(?i)\b(greatest hits|best of|anthology|the very best|essentials?|definitive collection|gold|platinum|hits collection|complete collection|hit collection|b-sides|rarities)\b").unwrap();
|
||||
static ref VARIOUS_PATTERNS: Regex = Regex::new(r"(?i)\b(various artists|v\.?a\.?|original soundtrack|ost)\b").unwrap();
|
||||
|
||||
// Live album patterns
|
||||
static ref LIVE_PATTERNS: Regex = Regex::new(r"(?i)\blive\b|\blive at\b|\blive in\b|\bin concert\b|\bunplugged\b|\bmtv unplugged\b|\bacoustic live\b|\balive\b|\bon stage\b").unwrap();
|
||||
static ref LIVE_MULTILANG: Regex = Regex::new(r"(?i)\b(en vivo|en directo|ao vivo|dal vivo|en direct|konzert|liveopname)\b|ライブ|라이브|콘서트").unwrap();
|
||||
static ref FM_BROADCAST: Regex = Regex::new(r"(?i)\b(fm broadcasts?|radio broadcasts?|broadcasts?)\b").unwrap();
|
||||
|
||||
// Studio album variant patterns
|
||||
static ref STUDIO_VARIANT: Regex = Regex::new(r"(?i)\b(deluxe|remaster|anniversary|expanded|special edition|collector|box set)\b").unwrap();
|
||||
|
||||
// EP/Single patterns
|
||||
static ref EP_PATTERNS: Regex = Regex::new(r"(?i)\b(- ep|\.ep|\(ep\))\b").unwrap();
|
||||
static ref EP_SUFFIX: Regex = Regex::new(r"(?i)\bep\s*$").unwrap();
|
||||
static ref SINGLE_PATTERNS: Regex = Regex::new(r"(?i)\b(- single|\(single\))\b").unwrap();
|
||||
}
|
||||
|
||||
/// Album category for filtering
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum AlbumCategory {
|
||||
Albums, // Studio albums (main discography)
|
||||
Tributes, // Albums by other artists
|
||||
Others, // Compilations, bootlegs, unofficial
|
||||
Live, // Live recordings
|
||||
Eps, // EPs and singles
|
||||
}
|
||||
|
||||
/// Check if album is by a different artist
|
||||
fn is_different_artist(album: &crate::api::Album, main_artist_id: u64) -> bool {
|
||||
album.artist.id != main_artist_id
|
||||
}
|
||||
|
||||
/// Check if this is an unofficial/bootleg release
|
||||
fn is_unofficial_release(title: &str, label: &str, track_count: u32, duration: u32) -> bool {
|
||||
// FM Broadcasts, bootlegs
|
||||
if BROADCAST_PATTERNS.is_match(title) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Interviews, spoken word
|
||||
if NON_MUSIC_PATTERNS.is_match(title) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Known bootleg labels
|
||||
if BOOTLEG_LABELS.is_match(label) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Podcast-like: single long track
|
||||
if track_count == 1 && duration >= 1200 {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if this is a compilation/greatest hits
|
||||
fn is_compilation_album(title: &str) -> bool {
|
||||
COMPILATION_PATTERNS.is_match(title) || VARIOUS_PATTERNS.is_match(title)
|
||||
}
|
||||
|
||||
/// Check if this is a live album
|
||||
fn is_live_album(title: &str) -> bool {
|
||||
// Don't classify FM broadcasts as live
|
||||
if FM_BROADCAST.is_match(title) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if LIVE_PATTERNS.is_match(title) || LIVE_MULTILANG.is_match(title) {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if this is a studio album (main discography material)
|
||||
fn is_studio_album(title: &str, track_count: u32, duration: u32) -> bool {
|
||||
// Single-track releases over 20 min are likely podcasts
|
||||
if track_count == 1 && duration >= 1200 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Deluxe/remastered editions are studio albums
|
||||
if STUDIO_VARIANT.is_match(title) && (track_count >= 7 || duration >= 2100) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Standard album: 7+ tracks or 25+ minutes
|
||||
if track_count >= 7 {
|
||||
return true;
|
||||
}
|
||||
if duration >= 1500 {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if this is an EP or Single
|
||||
fn is_ep_or_single(title: &str, track_count: u32, duration: u32) -> bool {
|
||||
// Explicitly marked as EP or Single
|
||||
if EP_PATTERNS.is_match(title) || EP_SUFFIX.is_match(title) || SINGLE_PATTERNS.is_match(title) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Very short releases: 1-4 tracks
|
||||
if track_count > 0 && track_count <= 4 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Under 15 minutes
|
||||
if duration > 0 && duration <= 900 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 5-6 tracks AND under 20 minutes
|
||||
if track_count >= 5 && track_count <= 6 && duration > 0 && duration <= 1200 {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Categorize an album (mirrors TypeScript categorizeAlbum)
|
||||
fn categorize_album(album: &crate::api::Album, main_artist_id: u64) -> AlbumCategory {
|
||||
let title = album.title.to_lowercase();
|
||||
let label = album.label.as_ref().map(|l| l.name.as_str()).unwrap_or("");
|
||||
let track_count = album.tracks_count.unwrap_or(0);
|
||||
let duration = album.duration.unwrap_or(0);
|
||||
|
||||
// 1. Albums by different artists -> Tributes
|
||||
if is_different_artist(album, main_artist_id) {
|
||||
return AlbumCategory::Tributes;
|
||||
}
|
||||
|
||||
// 2. Unofficial releases -> Others
|
||||
if is_unofficial_release(&title, label, track_count, duration) {
|
||||
return AlbumCategory::Others;
|
||||
}
|
||||
|
||||
// 3. Compilations -> Others
|
||||
if is_compilation_album(&title) {
|
||||
return AlbumCategory::Others;
|
||||
}
|
||||
|
||||
// 4. Live albums -> Live
|
||||
if is_live_album(&title) {
|
||||
return AlbumCategory::Live;
|
||||
}
|
||||
|
||||
// 5. Check if it's a studio album first
|
||||
if is_studio_album(&title, track_count, duration) {
|
||||
return AlbumCategory::Albums;
|
||||
}
|
||||
|
||||
// 6. EPs and Singles
|
||||
if is_ep_or_single(&title, track_count, duration) {
|
||||
return AlbumCategory::Eps;
|
||||
}
|
||||
|
||||
// 7. Default: treat as studio album
|
||||
AlbumCategory::Albums
|
||||
}
|
||||
|
||||
/// Count studio albums for an artist (fetches albums and filters)
|
||||
async fn count_studio_albums(
|
||||
client: &crate::api::QobuzClient,
|
||||
artist_id: u64,
|
||||
) -> usize {
|
||||
// Fetch artist with albums (limit to 50 for performance)
|
||||
match client.get_artist_with_pagination(artist_id, true, Some(50), None).await {
|
||||
Ok(artist) => {
|
||||
if let Some(albums) = artist.albums {
|
||||
albums
|
||||
.items
|
||||
.iter()
|
||||
.filter(|album| categorize_album(album, artist_id) == AlbumCategory::Albums)
|
||||
.count()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to fetch albums for artist {}: {}", artist_id, e);
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use tokio::sync::Mutex;
|
||||
use crate::api::client::QobuzClient;
|
||||
use crate::api::models::Quality;
|
||||
use crate::cache::AudioCache;
|
||||
use crate::config::audio_settings::AudioSettingsState;
|
||||
use crate::offline_cache::OfflineCacheState;
|
||||
use crate::player::PlaybackState;
|
||||
use crate::queue::QueueManager;
|
||||
@@ -16,10 +17,12 @@ use crate::AppState;
|
||||
#[tauri::command]
|
||||
pub async fn play_track(
|
||||
track_id: u64,
|
||||
duration_secs: Option<u64>,
|
||||
state: State<'_, AppState>,
|
||||
offline_cache: State<'_, OfflineCacheState>,
|
||||
audio_settings: State<'_, AudioSettingsState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: play_track {}", track_id);
|
||||
log::info!("Command: play_track {} (duration: {:?}s)", track_id, duration_secs);
|
||||
|
||||
// First check offline cache (persistent disk cache)
|
||||
{
|
||||
@@ -89,8 +92,20 @@ pub async fn play_track(
|
||||
}
|
||||
}
|
||||
|
||||
// Not in any cache - download and cache in memory
|
||||
log::info!("Track {} not in any cache, streaming...", track_id);
|
||||
// Not in any cache - check if streaming is enabled
|
||||
log::info!("Track {} not in any cache, checking streaming setting...", track_id);
|
||||
|
||||
// Check if stream_first_track is enabled
|
||||
let (stream_first_enabled, buffer_seconds) = {
|
||||
let store = audio_settings.store.lock().map_err(|e| format!("Lock error: {}", e))?;
|
||||
match store.get_settings() {
|
||||
Ok(settings) => (settings.stream_first_track, settings.stream_buffer_seconds),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to get audio settings, using defaults: {}", e);
|
||||
(false, 3)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let client = state.client.lock().await;
|
||||
|
||||
@@ -102,6 +117,59 @@ pub async fn play_track(
|
||||
|
||||
log::info!("Got stream URL for track {}", track_id);
|
||||
|
||||
if stream_first_enabled {
|
||||
// Use streaming playback - start playing before full download
|
||||
log::info!("🚀 Streaming mode enabled - starting stream-first playback for track {}", track_id);
|
||||
|
||||
// Get content length, audio info, and measured speed via HEAD request
|
||||
let stream_info = get_stream_info(&stream_url.url).await?;
|
||||
|
||||
log::info!(
|
||||
"📊 Stream info: {:.2} MB, {}Hz, {} channels, {:.1} MB/s",
|
||||
stream_info.content_length as f64 / (1024.0 * 1024.0),
|
||||
stream_info.sample_rate,
|
||||
stream_info.channels,
|
||||
stream_info.speed_mbps
|
||||
);
|
||||
|
||||
// Start streaming playback with dynamic buffer based on measured speed
|
||||
// The player will use from_speed_mbps internally
|
||||
let buffer_writer = state.player.play_streaming_dynamic(
|
||||
track_id,
|
||||
stream_info.sample_rate,
|
||||
stream_info.channels,
|
||||
stream_info.content_length,
|
||||
stream_info.speed_mbps,
|
||||
duration_secs.unwrap_or(0), // Use 0 if not provided
|
||||
)?;
|
||||
|
||||
// Release client lock before spawning background download
|
||||
drop(client);
|
||||
|
||||
// Spawn background task to download and push data to buffer
|
||||
let url = stream_url.url.clone();
|
||||
let cache_clone = cache.clone();
|
||||
let content_len = stream_info.content_length;
|
||||
tokio::spawn(async move {
|
||||
match download_and_stream(&url, buffer_writer, track_id, cache_clone, content_len).await {
|
||||
Ok(()) => log::info!("🎵 Track {} ready for instant replay from cache", track_id),
|
||||
Err(e) => log::error!("Streaming download failed for track {}: {}", track_id, e),
|
||||
}
|
||||
});
|
||||
|
||||
// Prefetch next track in background
|
||||
spawn_prefetch(
|
||||
state.client.clone(),
|
||||
state.audio_cache.clone(),
|
||||
&state.queue,
|
||||
);
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Standard download path (streaming disabled)
|
||||
log::info!("Standard download mode for track {}", track_id);
|
||||
|
||||
// Download the audio
|
||||
let audio_data = download_audio(&stream_url.url).await?;
|
||||
let data_size = audio_data.len();
|
||||
@@ -215,6 +283,289 @@ async fn download_audio(url: &str) -> Result<Vec<u8>, String> {
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
/// Stream info including measured download speed
|
||||
pub struct StreamInfo {
|
||||
pub content_length: u64,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u16,
|
||||
pub speed_mbps: f64,
|
||||
}
|
||||
|
||||
/// Get stream info (content length, sample rate, channels, speed) via HEAD request and initial bytes
|
||||
async fn get_stream_info(url: &str) -> Result<StreamInfo, String> {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
// First, do a HEAD request to get content length
|
||||
let head_response = client
|
||||
.head(url)
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed HEAD request: {}", e))?;
|
||||
|
||||
if !head_response.status().is_success() {
|
||||
return Err(format!("HEAD request failed: {}", head_response.status()));
|
||||
}
|
||||
|
||||
let content_length = head_response
|
||||
.headers()
|
||||
.get("content-length")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.ok_or_else(|| "No content-length header".to_string())?;
|
||||
|
||||
// Download first ~64KB to probe audio format and measure speed
|
||||
// This is enough for FLAC/M4A headers
|
||||
let start_time = Instant::now();
|
||||
|
||||
let range_response = client
|
||||
.get(url)
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.header("Range", "bytes=0-65535")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed range request: {}", e))?;
|
||||
|
||||
if !range_response.status().is_success() && range_response.status() != reqwest::StatusCode::PARTIAL_CONTENT {
|
||||
return Err(format!("Range request failed: {}", range_response.status()));
|
||||
}
|
||||
|
||||
let initial_bytes = range_response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read initial bytes: {}", e))?;
|
||||
|
||||
let elapsed = start_time.elapsed();
|
||||
let bytes_downloaded = initial_bytes.len() as f64;
|
||||
let speed_mbps = if elapsed.as_secs_f64() > 0.0 {
|
||||
(bytes_downloaded / elapsed.as_secs_f64()) / (1024.0 * 1024.0)
|
||||
} else {
|
||||
10.0 // Assume fast if instant
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"📶 Probe: {}KB in {:.0}ms = {:.1} MB/s",
|
||||
initial_bytes.len() / 1024,
|
||||
elapsed.as_millis(),
|
||||
speed_mbps
|
||||
);
|
||||
|
||||
// Try to extract audio format from initial bytes
|
||||
let (sample_rate, channels) = extract_audio_format_from_header(&initial_bytes)?;
|
||||
|
||||
Ok(StreamInfo {
|
||||
content_length,
|
||||
sample_rate,
|
||||
channels,
|
||||
speed_mbps,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract sample rate and channels from audio file header
|
||||
fn extract_audio_format_from_header(data: &[u8]) -> Result<(u32, u16), String> {
|
||||
use std::io::{BufReader, Cursor};
|
||||
use rodio::Decoder;
|
||||
|
||||
// Check if this is FLAC
|
||||
if data.len() >= 4 && &data[0..4] == b"fLaC" {
|
||||
// Parse FLAC STREAMINFO block
|
||||
// FLAC format: "fLaC" + METADATA_BLOCK_HEADER (4 bytes) + STREAMINFO
|
||||
if data.len() >= 26 {
|
||||
// STREAMINFO starts at byte 8
|
||||
// Bytes 10-12: sample rate (20 bits) + channels (3 bits) + bits per sample (5 bits)
|
||||
let sr_high = ((data[18] as u32) << 12) | ((data[19] as u32) << 4) | ((data[20] as u32) >> 4);
|
||||
let sample_rate = sr_high;
|
||||
let channels = ((data[20] >> 1) & 0x07) + 1;
|
||||
return Ok((sample_rate, channels as u16));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is M4A/MP4 (ftyp box)
|
||||
if data.len() >= 12 && &data[4..8] == b"ftyp" {
|
||||
// M4A uses AAC or ALAC, try symphonia probe
|
||||
use symphonia::core::formats::FormatOptions;
|
||||
use symphonia::core::io::MediaSourceStream;
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Hint;
|
||||
use symphonia::default::get_probe;
|
||||
|
||||
let cursor = Box::new(Cursor::new(data.to_vec())) as Box<dyn std::io::Read + Send + Sync>;
|
||||
// For probing, we need to create a MediaSource
|
||||
// Use a simple wrapper
|
||||
struct ProbeSource {
|
||||
inner: Cursor<Vec<u8>>,
|
||||
len: u64,
|
||||
}
|
||||
impl std::io::Read for ProbeSource {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
self.inner.read(buf)
|
||||
}
|
||||
}
|
||||
impl std::io::Seek for ProbeSource {
|
||||
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
|
||||
self.inner.seek(pos)
|
||||
}
|
||||
}
|
||||
impl symphonia::core::io::MediaSource for ProbeSource {
|
||||
fn is_seekable(&self) -> bool { true }
|
||||
fn byte_len(&self) -> Option<u64> { Some(self.len) }
|
||||
}
|
||||
|
||||
let len = data.len() as u64;
|
||||
let source = Box::new(ProbeSource { inner: Cursor::new(data.to_vec()), len });
|
||||
let mss = MediaSourceStream::new(source, Default::default());
|
||||
|
||||
let mut hint = Hint::new();
|
||||
hint.with_extension("m4a");
|
||||
|
||||
let format_opts = FormatOptions {
|
||||
enable_gapless: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if let Ok(probed) = get_probe().format(&hint, mss, &format_opts, &MetadataOptions::default()) {
|
||||
if let Some(track) = probed.format.default_track() {
|
||||
let sample_rate = track.codec_params.sample_rate.unwrap_or(44100);
|
||||
let channels = track.codec_params.channels.map(|c| c.count() as u16).unwrap_or(2);
|
||||
return Ok((sample_rate, channels));
|
||||
}
|
||||
}
|
||||
|
||||
// Default to common values for M4A
|
||||
return Ok((44100, 2));
|
||||
}
|
||||
|
||||
// Try rodio decoder for other formats
|
||||
match Decoder::new(BufReader::new(Cursor::new(data.to_vec()))) {
|
||||
Ok(decoder) => {
|
||||
use rodio::Source;
|
||||
Ok((decoder.sample_rate(), decoder.channels()))
|
||||
}
|
||||
Err(_) => {
|
||||
// Default fallback
|
||||
log::warn!("Could not determine audio format, using defaults (44100Hz, stereo)");
|
||||
Ok((44100, 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Download audio chunks and stream them to the buffer writer
|
||||
/// Also caches the complete data when download finishes
|
||||
async fn download_and_stream(
|
||||
url: &str,
|
||||
writer: crate::player::BufferWriter,
|
||||
track_id: u64,
|
||||
cache: Arc<AudioCache>,
|
||||
content_length: u64,
|
||||
) -> Result<(), String> {
|
||||
use std::time::{Duration, Instant};
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(300)) // Longer timeout for streaming
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
log::info!("📥 Starting streaming download for track {} ({:.2} MB total)",
|
||||
track_id,
|
||||
content_length as f64 / (1024.0 * 1024.0)
|
||||
);
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start stream: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Stream request failed: {}", response.status()));
|
||||
}
|
||||
|
||||
let mut all_data = Vec::with_capacity(content_length as usize);
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut bytes_received = 0u64;
|
||||
let start_time = Instant::now();
|
||||
let mut last_log_time = Instant::now();
|
||||
let mut last_log_bytes = 0u64;
|
||||
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let chunk = chunk_result.map_err(|e| format!("Stream chunk error: {}", e))?;
|
||||
bytes_received += chunk.len() as u64;
|
||||
|
||||
// Accumulate for caching
|
||||
all_data.extend_from_slice(&chunk);
|
||||
|
||||
// Push to streaming buffer
|
||||
if let Err(e) = writer.push_chunk(&chunk) {
|
||||
log::error!("Failed to push chunk to buffer: {}", e);
|
||||
}
|
||||
|
||||
// Log progress every ~500ms with speed info
|
||||
let now = Instant::now();
|
||||
if now.duration_since(last_log_time) >= Duration::from_millis(500) {
|
||||
let elapsed_total = start_time.elapsed().as_secs_f64();
|
||||
let elapsed_interval = now.duration_since(last_log_time).as_secs_f64();
|
||||
let bytes_this_interval = bytes_received - last_log_bytes;
|
||||
|
||||
// Current speed (MB/s)
|
||||
let current_speed = (bytes_this_interval as f64 / elapsed_interval) / (1024.0 * 1024.0);
|
||||
// Average speed
|
||||
let avg_speed = (bytes_received as f64 / elapsed_total) / (1024.0 * 1024.0);
|
||||
// Progress percentage
|
||||
let progress = (bytes_received as f64 / content_length as f64) * 100.0;
|
||||
// ETA
|
||||
let remaining_bytes = content_length.saturating_sub(bytes_received);
|
||||
let eta_secs = if avg_speed > 0.0 {
|
||||
remaining_bytes as f64 / (avg_speed * 1024.0 * 1024.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"📊 Download: {:.1}% ({:.2}/{:.2} MB) | Speed: {:.2} MB/s (avg: {:.2}) | ETA: {:.1}s",
|
||||
progress,
|
||||
bytes_received as f64 / (1024.0 * 1024.0),
|
||||
content_length as f64 / (1024.0 * 1024.0),
|
||||
current_speed,
|
||||
avg_speed,
|
||||
eta_secs
|
||||
);
|
||||
|
||||
last_log_time = now;
|
||||
last_log_bytes = bytes_received;
|
||||
}
|
||||
}
|
||||
|
||||
// Mark stream as complete
|
||||
if let Err(e) = writer.complete() {
|
||||
log::error!("Failed to mark buffer complete: {}", e);
|
||||
}
|
||||
|
||||
let total_time = start_time.elapsed();
|
||||
let avg_speed = (bytes_received as f64 / total_time.as_secs_f64()) / (1024.0 * 1024.0);
|
||||
|
||||
log::info!(
|
||||
"✅ Streaming download complete: {:.2} MB in {:.1}s ({:.2} MB/s avg)",
|
||||
bytes_received as f64 / (1024.0 * 1024.0),
|
||||
total_time.as_secs_f64(),
|
||||
avg_speed
|
||||
);
|
||||
|
||||
// Cache the complete file for future plays
|
||||
log::info!("💾 Caching track {} for future playback", track_id);
|
||||
cache.insert(track_id, all_data);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Number of Qobuz tracks to prefetch (not total tracks, just Qobuz)
|
||||
const QOBUZ_PREFETCH_COUNT: usize = 3;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::api::{Album, Artist, ArtistAlbums, SearchResultsPage, Track};
|
||||
use crate::api::{Album, Artist, ArtistAlbums, LabelDetail, SearchResultsPage, Track};
|
||||
use crate::api_cache::ApiCacheState;
|
||||
use crate::AppState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -19,11 +19,12 @@ pub async fn search_albums(
|
||||
query: String,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
search_type: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SearchResultsPage<Album>, String> {
|
||||
let client = state.client.lock().await;
|
||||
client
|
||||
.search_albums(&query, limit.unwrap_or(20), offset.unwrap_or(0))
|
||||
.search_albums(&query, limit.unwrap_or(20), offset.unwrap_or(0), search_type.as_deref())
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -33,11 +34,12 @@ pub async fn search_tracks(
|
||||
query: String,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
search_type: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SearchResultsPage<Track>, String> {
|
||||
let client = state.client.lock().await;
|
||||
client
|
||||
.search_tracks(&query, limit.unwrap_or(20), offset.unwrap_or(0))
|
||||
.search_tracks(&query, limit.unwrap_or(20), offset.unwrap_or(0), search_type.as_deref())
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -47,11 +49,12 @@ pub async fn search_artists(
|
||||
query: String,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
search_type: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SearchResultsPage<Artist>, String> {
|
||||
let client = state.client.lock().await;
|
||||
client
|
||||
.search_artists(&query, limit.unwrap_or(20), offset.unwrap_or(0))
|
||||
.search_artists(&query, limit.unwrap_or(20), offset.unwrap_or(0), search_type.as_deref())
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -59,14 +62,16 @@ pub async fn search_artists(
|
||||
#[tauri::command]
|
||||
pub async fn search_all(
|
||||
query: String,
|
||||
search_type: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SearchAllResults, String> {
|
||||
let client = state.client.lock().await;
|
||||
|
||||
let st = search_type.as_deref();
|
||||
|
||||
let (albums_result, tracks_result, artists_result) = tokio::join!(
|
||||
client.search_albums(&query, 30, 0),
|
||||
client.search_tracks(&query, 8, 0),
|
||||
client.search_artists(&query, 12, 0)
|
||||
client.search_albums(&query, 30, 0, st),
|
||||
client.search_tracks(&query, 8, 0, st),
|
||||
client.search_artists(&query, 12, 0, st)
|
||||
);
|
||||
|
||||
Ok(SearchAllResults {
|
||||
@@ -264,3 +269,25 @@ pub async fn get_similar_artists(
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Get label detail with albums
|
||||
#[tauri::command]
|
||||
pub async fn get_label(
|
||||
label_id: u64,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<LabelDetail, String> {
|
||||
log::info!(
|
||||
"Command: get_label {} limit={:?} offset={:?}",
|
||||
label_id,
|
||||
limit,
|
||||
offset
|
||||
);
|
||||
|
||||
let client = state.client.lock().await;
|
||||
client
|
||||
.get_label(label_id, limit.unwrap_or(100), offset.unwrap_or(0))
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
//! Smart Playlist Tauri commands
|
||||
//!
|
||||
//! Generates playlists based on MusicBrainz artist relationships
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::api::QobuzClient;
|
||||
use crate::musicbrainz::smart_playlists::{
|
||||
GeneratedPlaylist, PlaylistGenerationConfig, PlaylistRule,
|
||||
};
|
||||
use crate::musicbrainz::MusicBrainzSharedState;
|
||||
use crate::AppState;
|
||||
|
||||
/// Preview a smart playlist (get track list without creating)
|
||||
#[tauri::command]
|
||||
pub async fn smart_playlist_preview(
|
||||
rule: PlaylistRule,
|
||||
config: Option<PlaylistGenerationConfig>,
|
||||
mb_state: State<'_, MusicBrainzSharedState>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<GeneratedPlaylist, String> {
|
||||
let config = config.unwrap_or_default();
|
||||
|
||||
// Check if MusicBrainz is enabled
|
||||
if !mb_state.client.is_enabled().await {
|
||||
return Err("MusicBrainz integration is disabled".to_string());
|
||||
}
|
||||
|
||||
match &rule {
|
||||
PlaylistRule::BandMembers {
|
||||
artist_mbid,
|
||||
include_past_members,
|
||||
} => {
|
||||
generate_band_members_playlist(
|
||||
artist_mbid,
|
||||
*include_past_members,
|
||||
&config,
|
||||
&mb_state,
|
||||
&app_state,
|
||||
)
|
||||
.await
|
||||
}
|
||||
PlaylistRule::ArtistGroups { artist_mbid } => {
|
||||
generate_artist_groups_playlist(artist_mbid, &config, &mb_state, &app_state).await
|
||||
}
|
||||
PlaylistRule::CollaboratorNetwork { artist_mbid, depth } => {
|
||||
generate_collaborator_playlist(artist_mbid, *depth, &config, &mb_state, &app_state)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate playlist from band members' solo work
|
||||
async fn generate_band_members_playlist(
|
||||
artist_mbid: &str,
|
||||
include_past_members: bool,
|
||||
config: &PlaylistGenerationConfig,
|
||||
mb_state: &State<'_, MusicBrainzSharedState>,
|
||||
app_state: &State<'_, AppState>,
|
||||
) -> Result<GeneratedPlaylist, String> {
|
||||
// Get relationships
|
||||
let relationships = mb_state
|
||||
.client
|
||||
.get_artist_with_relations(artist_mbid)
|
||||
.await?;
|
||||
|
||||
// Get the band name for the playlist title
|
||||
let band_name = relationships.name.clone();
|
||||
|
||||
// Collect member names
|
||||
let mut member_names: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(relations) = &relationships.relations {
|
||||
for relation in relations {
|
||||
if relation.relation_type == "member of band" {
|
||||
if let Some(ref artist) = relation.artist {
|
||||
// Check direction - we want members of this band
|
||||
if relation.direction.as_deref() != Some("backward") {
|
||||
// This is a member
|
||||
let is_past = relation.ended == Some(true);
|
||||
if !is_past || include_past_members {
|
||||
member_names.push(artist.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if member_names.is_empty() {
|
||||
return Ok(GeneratedPlaylist {
|
||||
suggested_name: format!("{} Members", band_name),
|
||||
description: "No band members found in MusicBrainz".to_string(),
|
||||
rule: PlaylistRule::BandMembers {
|
||||
artist_mbid: artist_mbid.to_string(),
|
||||
include_past_members,
|
||||
},
|
||||
included_artists: vec![],
|
||||
track_ids: vec![],
|
||||
total_tracks_found: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Search Qobuz for each member and get their tracks
|
||||
let mut all_track_ids: Vec<u64> = Vec::new();
|
||||
let mut found_artists: Vec<String> = Vec::new();
|
||||
|
||||
let client = app_state.client.lock().await;
|
||||
|
||||
for member_name in &member_names {
|
||||
match search_artist_tracks(&client, member_name, config.max_tracks_per_artist).await {
|
||||
Ok(tracks) => {
|
||||
if !tracks.is_empty() {
|
||||
found_artists.push(member_name.clone());
|
||||
all_track_ids.extend(tracks);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to search tracks for {}: {}", member_name, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we've hit the limit
|
||||
if all_track_ids.len() >= config.max_total_tracks {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let total_found = all_track_ids.len();
|
||||
all_track_ids.truncate(config.max_total_tracks);
|
||||
|
||||
Ok(GeneratedPlaylist {
|
||||
suggested_name: format!("{} Members", band_name),
|
||||
description: format!(
|
||||
"Solo work by members of {}{}",
|
||||
band_name,
|
||||
if include_past_members {
|
||||
" (including past members)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
),
|
||||
rule: PlaylistRule::BandMembers {
|
||||
artist_mbid: artist_mbid.to_string(),
|
||||
include_past_members,
|
||||
},
|
||||
included_artists: found_artists,
|
||||
track_ids: all_track_ids,
|
||||
total_tracks_found: total_found,
|
||||
})
|
||||
}
|
||||
|
||||
/// Generate playlist from groups an artist is/was a member of
|
||||
async fn generate_artist_groups_playlist(
|
||||
artist_mbid: &str,
|
||||
config: &PlaylistGenerationConfig,
|
||||
mb_state: &State<'_, MusicBrainzSharedState>,
|
||||
app_state: &State<'_, AppState>,
|
||||
) -> Result<GeneratedPlaylist, String> {
|
||||
// Get relationships
|
||||
let relationships = mb_state
|
||||
.client
|
||||
.get_artist_with_relations(artist_mbid)
|
||||
.await?;
|
||||
|
||||
let artist_name = relationships.name.clone();
|
||||
|
||||
// Collect group names
|
||||
let mut group_names: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(relations) = &relationships.relations {
|
||||
for relation in relations {
|
||||
if relation.relation_type == "member of band" {
|
||||
if let Some(ref artist) = relation.artist {
|
||||
// Check direction - "backward" means this artist is a member OF that group
|
||||
if relation.direction.as_deref() == Some("backward") {
|
||||
group_names.push(artist.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if group_names.is_empty() {
|
||||
return Ok(GeneratedPlaylist {
|
||||
suggested_name: format!("{}'s Groups", artist_name),
|
||||
description: "No groups found in MusicBrainz".to_string(),
|
||||
rule: PlaylistRule::ArtistGroups {
|
||||
artist_mbid: artist_mbid.to_string(),
|
||||
},
|
||||
included_artists: vec![],
|
||||
track_ids: vec![],
|
||||
total_tracks_found: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Search Qobuz for each group and get their tracks
|
||||
let mut all_track_ids: Vec<u64> = Vec::new();
|
||||
let mut found_artists: Vec<String> = Vec::new();
|
||||
|
||||
let client = app_state.client.lock().await;
|
||||
|
||||
for group_name in &group_names {
|
||||
match search_artist_tracks(&client, group_name, config.max_tracks_per_artist).await {
|
||||
Ok(tracks) => {
|
||||
if !tracks.is_empty() {
|
||||
found_artists.push(group_name.clone());
|
||||
all_track_ids.extend(tracks);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to search tracks for {}: {}", group_name, e);
|
||||
}
|
||||
}
|
||||
|
||||
if all_track_ids.len() >= config.max_total_tracks {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let total_found = all_track_ids.len();
|
||||
all_track_ids.truncate(config.max_total_tracks);
|
||||
|
||||
Ok(GeneratedPlaylist {
|
||||
suggested_name: format!("{}'s Groups", artist_name),
|
||||
description: format!("Tracks from groups {} is/was a member of", artist_name),
|
||||
rule: PlaylistRule::ArtistGroups {
|
||||
artist_mbid: artist_mbid.to_string(),
|
||||
},
|
||||
included_artists: found_artists,
|
||||
track_ids: all_track_ids,
|
||||
total_tracks_found: total_found,
|
||||
})
|
||||
}
|
||||
|
||||
/// Generate playlist from collaborators
|
||||
async fn generate_collaborator_playlist(
|
||||
artist_mbid: &str,
|
||||
depth: u8,
|
||||
config: &PlaylistGenerationConfig,
|
||||
mb_state: &State<'_, MusicBrainzSharedState>,
|
||||
app_state: &State<'_, AppState>,
|
||||
) -> Result<GeneratedPlaylist, String> {
|
||||
// For now, only support depth=1 (direct collaborators)
|
||||
if depth > 1 {
|
||||
return Err("Collaborator depth > 1 not yet supported".to_string());
|
||||
}
|
||||
|
||||
// Get relationships
|
||||
let relationships = mb_state
|
||||
.client
|
||||
.get_artist_with_relations(artist_mbid)
|
||||
.await?;
|
||||
|
||||
let artist_name = relationships.name.clone();
|
||||
|
||||
// Collect collaborator names
|
||||
let mut collaborator_names: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(relations) = &relationships.relations {
|
||||
for relation in relations {
|
||||
if relation.relation_type == "collaboration" {
|
||||
if let Some(ref artist) = relation.artist {
|
||||
collaborator_names.push(artist.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if collaborator_names.is_empty() {
|
||||
return Ok(GeneratedPlaylist {
|
||||
suggested_name: format!("{} Collaborations", artist_name),
|
||||
description: "No collaborators found in MusicBrainz".to_string(),
|
||||
rule: PlaylistRule::CollaboratorNetwork {
|
||||
artist_mbid: artist_mbid.to_string(),
|
||||
depth,
|
||||
},
|
||||
included_artists: vec![],
|
||||
track_ids: vec![],
|
||||
total_tracks_found: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Search Qobuz for each collaborator
|
||||
let mut all_track_ids: Vec<u64> = Vec::new();
|
||||
let mut found_artists: Vec<String> = Vec::new();
|
||||
|
||||
let client = app_state.client.lock().await;
|
||||
|
||||
for collab_name in &collaborator_names {
|
||||
match search_artist_tracks(&client, collab_name, config.max_tracks_per_artist).await {
|
||||
Ok(tracks) => {
|
||||
if !tracks.is_empty() {
|
||||
found_artists.push(collab_name.clone());
|
||||
all_track_ids.extend(tracks);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to search tracks for {}: {}", collab_name, e);
|
||||
}
|
||||
}
|
||||
|
||||
if all_track_ids.len() >= config.max_total_tracks {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let total_found = all_track_ids.len();
|
||||
all_track_ids.truncate(config.max_total_tracks);
|
||||
|
||||
Ok(GeneratedPlaylist {
|
||||
suggested_name: format!("{} Collaborations", artist_name),
|
||||
description: format!("Tracks from artists who have collaborated with {}", artist_name),
|
||||
rule: PlaylistRule::CollaboratorNetwork {
|
||||
artist_mbid: artist_mbid.to_string(),
|
||||
depth,
|
||||
},
|
||||
included_artists: found_artists,
|
||||
track_ids: all_track_ids,
|
||||
total_tracks_found: total_found,
|
||||
})
|
||||
}
|
||||
|
||||
/// Search for an artist on Qobuz and get their top tracks
|
||||
async fn search_artist_tracks(
|
||||
client: &QobuzClient,
|
||||
artist_name: &str,
|
||||
max_tracks: usize,
|
||||
) -> Result<Vec<u64>, String> {
|
||||
// Search for the artist
|
||||
let search_results = client
|
||||
.search_artists(artist_name, 5, 0, None)
|
||||
.await
|
||||
.map_err(|e| format!("Search failed: {}", e))?;
|
||||
|
||||
if search_results.items.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// Find exact or close match
|
||||
let artist = search_results
|
||||
.items
|
||||
.iter()
|
||||
.find(|a| a.name.to_lowercase() == artist_name.to_lowercase())
|
||||
.or_else(|| search_results.items.first());
|
||||
|
||||
let Some(artist) = artist else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
|
||||
let artist_id = artist.id;
|
||||
|
||||
// Get the artist's top tracks by searching with artist name
|
||||
let track_results = client
|
||||
.search_tracks(artist_name, max_tracks as u32, 0, None)
|
||||
.await
|
||||
.map_err(|e| format!("Track search failed: {}", e))?;
|
||||
|
||||
// Filter tracks by this specific artist and extract IDs
|
||||
let track_ids: Vec<u64> = track_results
|
||||
.items
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
// Check if performer matches
|
||||
t.performer
|
||||
.as_ref()
|
||||
.map(|p| p.id == artist_id)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|t| t.id)
|
||||
.take(max_tracks)
|
||||
.collect();
|
||||
|
||||
Ok(track_ids)
|
||||
}
|
||||
|
||||
/// Get the artist MBID for a given artist name (helper for frontend)
|
||||
#[tauri::command]
|
||||
pub async fn smart_playlist_resolve_artist(
|
||||
artist_name: String,
|
||||
mb_state: State<'_, MusicBrainzSharedState>,
|
||||
) -> Result<Option<String>, String> {
|
||||
if !mb_state.client.is_enabled().await {
|
||||
return Err("MusicBrainz integration is disabled".to_string());
|
||||
}
|
||||
|
||||
let result = mb_state.client.search_artist(&artist_name).await?;
|
||||
|
||||
// Find best match with high confidence
|
||||
let best_match = result
|
||||
.artists
|
||||
.iter()
|
||||
.find(|a| a.score.unwrap_or(0) >= 90);
|
||||
|
||||
Ok(best_match.map(|a| a.id.clone()))
|
||||
}
|
||||
|
||||
/// Get available playlist types for an artist
|
||||
#[tauri::command]
|
||||
pub async fn smart_playlist_get_available_types(
|
||||
artist_mbid: String,
|
||||
mb_state: State<'_, MusicBrainzSharedState>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
if !mb_state.client.is_enabled().await {
|
||||
return Err("MusicBrainz integration is disabled".to_string());
|
||||
}
|
||||
|
||||
let relationships = mb_state
|
||||
.client
|
||||
.get_artist_with_relations(&artist_mbid)
|
||||
.await?;
|
||||
|
||||
let mut available: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(relations) = &relationships.relations {
|
||||
let mut has_members = false;
|
||||
let mut has_groups = false;
|
||||
let mut has_collaborators = false;
|
||||
|
||||
for relation in relations {
|
||||
match relation.relation_type.as_str() {
|
||||
"member of band" => {
|
||||
if relation.direction.as_deref() == Some("backward") {
|
||||
has_groups = true;
|
||||
} else {
|
||||
has_members = true;
|
||||
}
|
||||
}
|
||||
"collaboration" => {
|
||||
has_collaborators = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if has_members {
|
||||
available.push("band_members".to_string());
|
||||
}
|
||||
if has_groups {
|
||||
available.push("artist_groups".to_string());
|
||||
}
|
||||
if has_collaborators {
|
||||
available.push("collaborator_network".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(available)
|
||||
}
|
||||
@@ -16,6 +16,10 @@ pub struct AudioSettings {
|
||||
pub backend_type: Option<AudioBackendType>, // None = auto-detect
|
||||
pub alsa_plugin: Option<AlsaPlugin>, // Only used when backend is ALSA
|
||||
pub alsa_hardware_volume: bool, // Use ALSA mixer for volume (only with hw: devices)
|
||||
/// When true, uncached tracks start playing via streaming instead of waiting for full download
|
||||
pub stream_first_track: bool,
|
||||
/// Initial buffer size in seconds before starting streaming playback (1-10, default 3)
|
||||
pub stream_buffer_seconds: u8,
|
||||
}
|
||||
|
||||
impl Default for AudioSettings {
|
||||
@@ -28,6 +32,8 @@ impl Default for AudioSettings {
|
||||
backend_type: None, // Auto-detect (PipeWire if available, else ALSA)
|
||||
alsa_plugin: Some(AlsaPlugin::Hw), // Default to hw (bit-perfect)
|
||||
alsa_hardware_volume: false, // Disabled by default (maximum compatibility)
|
||||
stream_first_track: false, // Disabled by default (current behavior)
|
||||
stream_buffer_seconds: 3, // 3 seconds initial buffer
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,7 +64,9 @@ impl AudioSettingsStore {
|
||||
preferred_sample_rate INTEGER,
|
||||
backend_type TEXT,
|
||||
alsa_plugin TEXT,
|
||||
alsa_hardware_volume INTEGER NOT NULL DEFAULT 0
|
||||
alsa_hardware_volume INTEGER NOT NULL DEFAULT 0,
|
||||
stream_first_track INTEGER NOT NULL DEFAULT 0,
|
||||
stream_buffer_seconds INTEGER NOT NULL DEFAULT 3
|
||||
);
|
||||
INSERT OR IGNORE INTO audio_settings (id, exclusive_mode, dac_passthrough)
|
||||
VALUES (1, 0, 0);"
|
||||
@@ -68,6 +76,8 @@ impl AudioSettingsStore {
|
||||
let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN backend_type TEXT", []);
|
||||
let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN alsa_plugin TEXT", []);
|
||||
let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN alsa_hardware_volume INTEGER DEFAULT 0", []);
|
||||
let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN stream_first_track INTEGER DEFAULT 0", []);
|
||||
let _ = conn.execute("ALTER TABLE audio_settings ADD COLUMN stream_buffer_seconds INTEGER DEFAULT 3", []);
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
@@ -75,7 +85,7 @@ impl AudioSettingsStore {
|
||||
pub fn get_settings(&self) -> Result<AudioSettings, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume FROM audio_settings WHERE id = 1",
|
||||
"SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds FROM audio_settings WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
// Parse backend_type from JSON string
|
||||
@@ -96,6 +106,8 @@ impl AudioSettingsStore {
|
||||
backend_type,
|
||||
alsa_plugin,
|
||||
alsa_hardware_volume: row.get::<_, Option<i64>>(6)?.unwrap_or(0) != 0,
|
||||
stream_first_track: row.get::<_, Option<i64>>(7)?.unwrap_or(0) != 0,
|
||||
stream_buffer_seconds: row.get::<_, Option<i64>>(8)?.unwrap_or(3) as u8,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -181,6 +193,28 @@ impl AudioSettingsStore {
|
||||
.map_err(|e| format!("Failed to set ALSA hardware volume: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_stream_first_track(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET stream_first_track = ?1 WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set stream first track: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_stream_buffer_seconds(&self, seconds: u8) -> Result<(), String> {
|
||||
// Clamp to valid range 1-10
|
||||
let clamped = seconds.clamp(1, 10);
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE audio_settings SET stream_buffer_seconds = ?1 WHERE id = 1",
|
||||
params![clamped as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set stream buffer seconds: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe wrapper
|
||||
@@ -267,3 +301,21 @@ pub fn set_audio_alsa_hardware_volume(
|
||||
let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?;
|
||||
store.set_alsa_hardware_volume(enabled)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_audio_stream_first_track(
|
||||
state: tauri::State<'_, AudioSettingsState>,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?;
|
||||
store.set_stream_first_track(enabled)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_audio_stream_buffer_seconds(
|
||||
state: tauri::State<'_, AudioSettingsState>,
|
||||
seconds: u8,
|
||||
) -> Result<(), String> {
|
||||
let store = state.store.lock().map_err(|e| format!("Lock error: {}", e))?;
|
||||
store.set_stream_buffer_seconds(seconds)
|
||||
}
|
||||
@@ -15,8 +15,10 @@ pub mod offline_cache;
|
||||
pub mod flatpak;
|
||||
pub mod lastfm;
|
||||
pub mod library;
|
||||
pub mod listenbrainz;
|
||||
pub mod lyrics;
|
||||
pub mod media_controls;
|
||||
pub mod musicbrainz;
|
||||
pub mod network;
|
||||
pub mod offline;
|
||||
pub mod playback_context;
|
||||
@@ -192,6 +194,12 @@ pub fn run() {
|
||||
// Initialize subscription validity tracking (for offline download compliance)
|
||||
let subscription_state = config::create_subscription_state()
|
||||
.expect("Failed to initialize subscription state");
|
||||
// Initialize MusicBrainz integration state
|
||||
let musicbrainz_state = musicbrainz::MusicBrainzSharedState::new()
|
||||
.expect("Failed to initialize MusicBrainz state");
|
||||
// Initialize ListenBrainz integration state
|
||||
let listenbrainz_state = listenbrainz::ListenBrainzSharedState::new()
|
||||
.expect("Failed to initialize ListenBrainz state");
|
||||
|
||||
// Read saved audio device and settings for player initialization
|
||||
let (saved_device, audio_settings) = audio_settings_state
|
||||
@@ -401,6 +409,8 @@ pub fn run() {
|
||||
.manage(playback_prefs_state)
|
||||
.manage(favorites_prefs_state)
|
||||
.manage(tray_settings_state)
|
||||
.manage(musicbrainz_state)
|
||||
.manage(listenbrainz_state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Auth commands
|
||||
commands::init_client,
|
||||
@@ -426,6 +436,7 @@ pub fn run() {
|
||||
commands::get_artist_detail,
|
||||
commands::get_artist_albums,
|
||||
commands::get_similar_artists,
|
||||
commands::get_label,
|
||||
// Playback commands
|
||||
commands::play_track,
|
||||
commands::prefetch_track,
|
||||
@@ -688,6 +699,8 @@ pub fn run() {
|
||||
config::audio_settings::set_audio_backend_type,
|
||||
config::audio_settings::set_audio_alsa_plugin,
|
||||
config::audio_settings::set_audio_alsa_hardware_volume,
|
||||
config::audio_settings::set_audio_stream_first_track,
|
||||
config::audio_settings::set_audio_stream_buffer_seconds,
|
||||
// Audio backend commands
|
||||
commands::get_available_backends,
|
||||
commands::get_devices_for_backend,
|
||||
@@ -740,6 +753,38 @@ pub fn run() {
|
||||
config::tray_settings::set_enable_tray,
|
||||
config::tray_settings::set_minimize_to_tray,
|
||||
config::tray_settings::set_close_to_tray,
|
||||
// MusicBrainz integration commands
|
||||
commands::musicbrainz_resolve_track,
|
||||
commands::musicbrainz_resolve_artist,
|
||||
commands::musicbrainz_resolve_release,
|
||||
commands::musicbrainz_get_artist_relationships,
|
||||
commands::musicbrainz_is_enabled,
|
||||
commands::musicbrainz_set_enabled,
|
||||
commands::musicbrainz_get_cache_stats,
|
||||
commands::musicbrainz_clear_cache,
|
||||
commands::musicbrainz_cleanup_cache,
|
||||
// Musician resolution commands
|
||||
commands::resolve_musician,
|
||||
commands::get_musician_appearances,
|
||||
// ListenBrainz integration commands
|
||||
commands::listenbrainz_get_status,
|
||||
commands::listenbrainz_is_enabled,
|
||||
commands::listenbrainz_set_enabled,
|
||||
commands::listenbrainz_connect,
|
||||
commands::listenbrainz_disconnect,
|
||||
commands::listenbrainz_now_playing,
|
||||
commands::listenbrainz_scrobble,
|
||||
commands::listenbrainz_queue_listen,
|
||||
commands::listenbrainz_get_queue,
|
||||
commands::listenbrainz_get_queue_count,
|
||||
commands::listenbrainz_mark_sent,
|
||||
commands::listenbrainz_flush_queue,
|
||||
commands::listenbrainz_clear_queue,
|
||||
commands::listenbrainz_cleanup_queue,
|
||||
// Smart playlist generation commands
|
||||
commands::smart_playlist_preview,
|
||||
commands::smart_playlist_resolve_artist,
|
||||
commands::smart_playlist_get_available_types,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
//! ListenBrainz cache and queue layer
|
||||
//!
|
||||
//! SQLite-based queue for offline listens and token persistence
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::models::*;
|
||||
|
||||
/// Max age for queued listens (30 days in seconds)
|
||||
const QUEUE_MAX_AGE_SECS: i64 = 30 * 24 * 60 * 60;
|
||||
|
||||
/// Max queue size
|
||||
const QUEUE_MAX_SIZE: i64 = 500;
|
||||
|
||||
/// ListenBrainz cache state for Tauri
|
||||
pub struct ListenBrainzCacheState {
|
||||
pub cache: Arc<Mutex<ListenBrainzCache>>,
|
||||
}
|
||||
|
||||
impl ListenBrainzCacheState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz")
|
||||
.join("cache");
|
||||
|
||||
std::fs::create_dir_all(&data_dir)
|
||||
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
|
||||
|
||||
let db_path = data_dir.join("listenbrainz_cache.db");
|
||||
let cache = ListenBrainzCache::new(&db_path)?;
|
||||
|
||||
log::info!("ListenBrainz cache initialized at {:?}", db_path);
|
||||
|
||||
Ok(Self {
|
||||
cache: Arc::new(Mutex::new(cache)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// ListenBrainz SQLite cache
|
||||
pub struct ListenBrainzCache {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl ListenBrainzCache {
|
||||
pub fn new(path: &Path) -> Result<Self, String> {
|
||||
let conn = Connection::open(path)
|
||||
.map_err(|e| format!("Failed to open ListenBrainz cache: {}", e))?;
|
||||
|
||||
// Enable WAL mode for better concurrency
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to set pragmas: {}", e))?;
|
||||
|
||||
let cache = Self { conn };
|
||||
cache.init()?;
|
||||
Ok(cache)
|
||||
}
|
||||
|
||||
fn init(&self) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute_batch(
|
||||
r#"
|
||||
-- Token persistence
|
||||
CREATE TABLE IF NOT EXISTS lb_credentials (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
token TEXT,
|
||||
user_name TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT OR IGNORE INTO lb_credentials (id, updated_at) VALUES (1, 0);
|
||||
|
||||
-- Offline listen queue
|
||||
CREATE TABLE IF NOT EXISTS lb_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
listened_at INTEGER NOT NULL,
|
||||
artist_name TEXT NOT NULL,
|
||||
track_name TEXT NOT NULL,
|
||||
release_name TEXT,
|
||||
recording_mbid TEXT,
|
||||
release_mbid TEXT,
|
||||
artist_mbids TEXT,
|
||||
isrc TEXT,
|
||||
duration_ms INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
attempts INTEGER DEFAULT 0,
|
||||
sent INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_lb_queue_sent ON lb_queue(sent);
|
||||
CREATE INDEX IF NOT EXISTS idx_lb_queue_created ON lb_queue(created_at);
|
||||
|
||||
-- Settings
|
||||
CREATE TABLE IF NOT EXISTS lb_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
enabled INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
INSERT OR IGNORE INTO lb_settings (id, enabled) VALUES (1, 1);
|
||||
"#,
|
||||
)
|
||||
.map_err(|e| format!("Failed to initialize ListenBrainz cache: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn current_timestamp() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ============ Credentials ============
|
||||
|
||||
/// Get saved token and username
|
||||
pub fn get_credentials(&self) -> Result<(Option<String>, Option<String>), String> {
|
||||
let result: (Option<String>, Option<String>) = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT token, user_name FROM lb_credentials WHERE id = 1",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.map_err(|e| format!("Failed to get credentials: {}", e))?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Save token and username
|
||||
pub fn set_credentials(&self, token: Option<&str>, user_name: Option<&str>) -> Result<(), String> {
|
||||
let now = Self::current_timestamp();
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE lb_credentials SET token = ?, user_name = ?, updated_at = ? WHERE id = 1",
|
||||
params![token, user_name, now],
|
||||
)
|
||||
.map_err(|e| format!("Failed to save credentials: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear saved credentials
|
||||
pub fn clear_credentials(&self) -> Result<(), String> {
|
||||
self.set_credentials(None, None)
|
||||
}
|
||||
|
||||
// ============ Settings ============
|
||||
|
||||
/// Check if ListenBrainz is enabled
|
||||
pub fn is_enabled(&self) -> Result<bool, String> {
|
||||
let enabled: i64 = self
|
||||
.conn
|
||||
.query_row("SELECT enabled FROM lb_settings WHERE id = 1", [], |row| row.get(0))
|
||||
.unwrap_or(1);
|
||||
Ok(enabled != 0)
|
||||
}
|
||||
|
||||
/// Set enabled state
|
||||
pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE lb_settings SET enabled = ? WHERE id = 1",
|
||||
params![enabled as i64],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set enabled state: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Queue ============
|
||||
|
||||
/// Queue a listen for later submission
|
||||
pub fn queue_listen(
|
||||
&self,
|
||||
listened_at: i64,
|
||||
artist: &str,
|
||||
track: &str,
|
||||
album: Option<&str>,
|
||||
recording_mbid: Option<&str>,
|
||||
release_mbid: Option<&str>,
|
||||
artist_mbids: Option<&[String]>,
|
||||
isrc: Option<&str>,
|
||||
duration_ms: Option<u64>,
|
||||
) -> Result<i64, String> {
|
||||
// First, enforce max queue size by removing oldest entries
|
||||
self.enforce_queue_limits()?;
|
||||
|
||||
let now = Self::current_timestamp();
|
||||
let artist_mbids_json = artist_mbids.map(|ids| serde_json::to_string(ids).unwrap_or_default());
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO lb_queue (listened_at, artist_name, track_name, release_name,
|
||||
recording_mbid, release_mbid, artist_mbids, isrc,
|
||||
duration_ms, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
params![
|
||||
listened_at,
|
||||
artist,
|
||||
track,
|
||||
album,
|
||||
recording_mbid,
|
||||
release_mbid,
|
||||
artist_mbids_json,
|
||||
isrc,
|
||||
duration_ms.map(|d| d as i64),
|
||||
now
|
||||
],
|
||||
)
|
||||
.map_err(|e| format!("Failed to queue listen: {}", e))?;
|
||||
|
||||
Ok(self.conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// Enforce queue size and age limits
|
||||
fn enforce_queue_limits(&self) -> Result<(), String> {
|
||||
let now = Self::current_timestamp();
|
||||
let cutoff = now - QUEUE_MAX_AGE_SECS;
|
||||
|
||||
// Remove old entries
|
||||
self.conn
|
||||
.execute("DELETE FROM lb_queue WHERE created_at < ?", params![cutoff])
|
||||
.map_err(|e| format!("Failed to cleanup old queue entries: {}", e))?;
|
||||
|
||||
// Remove excess entries (keep newest)
|
||||
let count: i64 = self
|
||||
.conn
|
||||
.query_row("SELECT COUNT(*) FROM lb_queue WHERE sent = 0", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
if count >= QUEUE_MAX_SIZE {
|
||||
let to_remove = count - QUEUE_MAX_SIZE + 1;
|
||||
self.conn
|
||||
.execute(
|
||||
"DELETE FROM lb_queue WHERE id IN (
|
||||
SELECT id FROM lb_queue WHERE sent = 0
|
||||
ORDER BY listened_at ASC LIMIT ?
|
||||
)",
|
||||
params![to_remove],
|
||||
)
|
||||
.map_err(|e| format!("Failed to trim queue: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get queued listens (unsent, up to limit)
|
||||
pub fn get_queued_listens(&self, limit: u32) -> Result<Vec<QueuedListen>, String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT id, listened_at, artist_name, track_name, release_name,
|
||||
recording_mbid, release_mbid, artist_mbids, isrc,
|
||||
duration_ms, created_at, attempts, sent
|
||||
FROM lb_queue WHERE sent = 0
|
||||
ORDER BY listened_at ASC LIMIT ?",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare statement: {}", e))?;
|
||||
|
||||
let listens = stmt
|
||||
.query_map(params![limit], |row| {
|
||||
let artist_mbids_json: Option<String> = row.get(7)?;
|
||||
let artist_mbids: Option<Vec<String>> = artist_mbids_json
|
||||
.and_then(|json| serde_json::from_str(&json).ok());
|
||||
|
||||
Ok(QueuedListen {
|
||||
id: row.get(0)?,
|
||||
listened_at: row.get(1)?,
|
||||
artist_name: row.get(2)?,
|
||||
track_name: row.get(3)?,
|
||||
release_name: row.get(4)?,
|
||||
recording_mbid: row.get(5)?,
|
||||
release_mbid: row.get(6)?,
|
||||
artist_mbids,
|
||||
isrc: row.get(8)?,
|
||||
duration_ms: row.get::<_, Option<i64>>(9)?.map(|d| d as u64),
|
||||
created_at: row.get(10)?,
|
||||
attempts: row.get(11)?,
|
||||
sent: row.get::<_, i64>(12)? != 0,
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query queued listens: {}", e))?;
|
||||
|
||||
listens
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("Failed to collect queued listens: {}", e))
|
||||
}
|
||||
|
||||
/// Mark listens as sent
|
||||
pub fn mark_listens_sent(&self, ids: &[i64]) -> Result<(), String> {
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let placeholders: Vec<String> = ids.iter().map(|_| "?".to_string()).collect();
|
||||
let sql = format!(
|
||||
"UPDATE lb_queue SET sent = 1 WHERE id IN ({})",
|
||||
placeholders.join(",")
|
||||
);
|
||||
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(&sql)
|
||||
.map_err(|e| format!("Failed to prepare statement: {}", e))?;
|
||||
|
||||
let params: Vec<&dyn rusqlite::ToSql> = ids.iter().map(|id| id as &dyn rusqlite::ToSql).collect();
|
||||
stmt.execute(params.as_slice())
|
||||
.map_err(|e| format!("Failed to mark listens as sent: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Increment attempt count for a listen
|
||||
pub fn increment_attempts(&self, id: i64) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE lb_queue SET attempts = attempts + 1 WHERE id = ?",
|
||||
params![id],
|
||||
)
|
||||
.map_err(|e| format!("Failed to increment attempts: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get count of queued (unsent) listens
|
||||
pub fn get_queue_count(&self) -> Result<u32, String> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM lb_queue WHERE sent = 0",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.map(|count| count as u32)
|
||||
.map_err(|e| format!("Failed to count queued listens: {}", e))
|
||||
}
|
||||
|
||||
/// Clear all queued listens
|
||||
pub fn clear_queue(&self) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute("DELETE FROM lb_queue", [])
|
||||
.map_err(|e| format!("Failed to clear queue: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cleanup sent listens older than specified days
|
||||
pub fn cleanup_sent_listens(&self, older_than_days: u32) -> Result<u32, String> {
|
||||
let cutoff = Self::current_timestamp() - (older_than_days as i64 * 24 * 60 * 60);
|
||||
|
||||
let deleted = self
|
||||
.conn
|
||||
.execute(
|
||||
"DELETE FROM lb_queue WHERE sent = 1 AND created_at < ?",
|
||||
params![cutoff],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cleanup sent listens: {}", e))?;
|
||||
|
||||
Ok(deleted as u32)
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue statistics
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QueueStats {
|
||||
pub pending_count: u32,
|
||||
pub total_count: u32,
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
//! ListenBrainz API client
|
||||
//!
|
||||
//! Direct client for ListenBrainz submissions (no proxy needed - uses user token)
|
||||
|
||||
use reqwest::Client;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::models::*;
|
||||
|
||||
/// ListenBrainz API base URL
|
||||
const LISTENBRAINZ_API_URL: &str = "https://api.listenbrainz.org/1";
|
||||
|
||||
/// ListenBrainz client configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ListenBrainzConfig {
|
||||
/// Whether ListenBrainz integration is enabled
|
||||
pub enabled: bool,
|
||||
/// User token from listenbrainz.org
|
||||
pub token: Option<String>,
|
||||
/// Username (set after token validation)
|
||||
pub user_name: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ListenBrainzConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
token: None,
|
||||
user_name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ListenBrainz API client
|
||||
pub struct ListenBrainzClient {
|
||||
client: Client,
|
||||
config: Arc<Mutex<ListenBrainzConfig>>,
|
||||
}
|
||||
|
||||
impl Default for ListenBrainzClient {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ListenBrainzClient {
|
||||
pub fn new() -> Self {
|
||||
Self::with_config(ListenBrainzConfig::default())
|
||||
}
|
||||
|
||||
pub fn with_config(config: ListenBrainzConfig) -> Self {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let user_agent = format!(
|
||||
"QBZ/{} (https://github.com/vicrodh/qbz; qbz@vicrodh.dev)",
|
||||
version
|
||||
);
|
||||
|
||||
let client = Client::builder()
|
||||
.user_agent(&user_agent)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
|
||||
Self {
|
||||
client,
|
||||
config: Arc::new(Mutex::new(config)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if ListenBrainz integration is enabled
|
||||
pub async fn is_enabled(&self) -> bool {
|
||||
self.config.lock().await.enabled
|
||||
}
|
||||
|
||||
/// Enable or disable ListenBrainz integration
|
||||
pub async fn set_enabled(&self, enabled: bool) {
|
||||
self.config.lock().await.enabled = enabled;
|
||||
}
|
||||
|
||||
/// Check if authenticated (has valid token)
|
||||
pub async fn is_authenticated(&self) -> bool {
|
||||
let config = self.config.lock().await;
|
||||
config.token.is_some() && config.user_name.is_some()
|
||||
}
|
||||
|
||||
/// Get current status
|
||||
pub async fn get_status(&self) -> ListenBrainzStatus {
|
||||
let config = self.config.lock().await;
|
||||
ListenBrainzStatus {
|
||||
connected: config.token.is_some() && config.user_name.is_some(),
|
||||
user_name: config.user_name.clone(),
|
||||
enabled: config.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set user token and validate it
|
||||
pub async fn set_token(&self, token: &str) -> Result<UserInfo, String> {
|
||||
// Validate token first
|
||||
let validation = self.validate_token(token).await?;
|
||||
|
||||
if !validation.valid {
|
||||
return Err(validation.message);
|
||||
}
|
||||
|
||||
let user_name = validation.user_name.ok_or("Token valid but no username returned")?;
|
||||
|
||||
// Store validated token and username
|
||||
{
|
||||
let mut config = self.config.lock().await;
|
||||
config.token = Some(token.to_string());
|
||||
config.user_name = Some(user_name.clone());
|
||||
}
|
||||
|
||||
log::info!("ListenBrainz connected as: {}", user_name);
|
||||
|
||||
Ok(UserInfo { user_name })
|
||||
}
|
||||
|
||||
/// Restore token from saved session (without re-validating)
|
||||
pub async fn restore_token(&self, token: String, user_name: String) {
|
||||
let mut config = self.config.lock().await;
|
||||
config.token = Some(token);
|
||||
config.user_name = Some(user_name);
|
||||
}
|
||||
|
||||
/// Get current token (for persistence)
|
||||
pub async fn get_token(&self) -> Option<String> {
|
||||
self.config.lock().await.token.clone()
|
||||
}
|
||||
|
||||
/// Get current username
|
||||
pub async fn get_user_name(&self) -> Option<String> {
|
||||
self.config.lock().await.user_name.clone()
|
||||
}
|
||||
|
||||
/// Disconnect (clear token)
|
||||
pub async fn disconnect(&self) {
|
||||
let mut config = self.config.lock().await;
|
||||
config.token = None;
|
||||
config.user_name = None;
|
||||
log::info!("ListenBrainz disconnected");
|
||||
}
|
||||
|
||||
/// Validate a token with ListenBrainz API
|
||||
async fn validate_token(&self, token: &str) -> Result<TokenValidationResponse, String> {
|
||||
let url = format!("{}/validate-token", LISTENBRAINZ_API_URL);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Token {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to validate token: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Token validation failed: {} - {}", status, text));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<TokenValidationResponse>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse validation response: {}", e))
|
||||
}
|
||||
|
||||
/// Submit "now playing" notification
|
||||
pub async fn submit_playing_now(
|
||||
&self,
|
||||
artist: &str,
|
||||
track: &str,
|
||||
album: Option<&str>,
|
||||
additional_info: Option<AdditionalInfo>,
|
||||
) -> Result<(), String> {
|
||||
let token = {
|
||||
let config = self.config.lock().await;
|
||||
if !config.enabled {
|
||||
return Ok(()); // Silently skip if disabled
|
||||
}
|
||||
config.token.clone()
|
||||
};
|
||||
|
||||
let token = token.ok_or("Not authenticated with ListenBrainz")?;
|
||||
|
||||
let mut info = additional_info.unwrap_or_default();
|
||||
// Ensure QBZ identifiers are set
|
||||
let version = env!("CARGO_PKG_VERSION").to_string();
|
||||
info.media_player = "QBZ".to_string();
|
||||
info.media_player_version = version.clone();
|
||||
info.submission_client = "QBZ".to_string();
|
||||
info.submission_client_version = version;
|
||||
|
||||
let payload = SubmitListensPayload {
|
||||
listen_type: ListenType::PlayingNow,
|
||||
payload: vec![Listen {
|
||||
listened_at: None, // Not used for playing_now
|
||||
track_metadata: TrackMetadata {
|
||||
artist_name: artist.to_string(),
|
||||
track_name: track.to_string(),
|
||||
release_name: album.map(|s| s.to_string()),
|
||||
additional_info: Some(info),
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
self.submit_listens(&token, &payload).await
|
||||
}
|
||||
|
||||
/// Submit a scrobble (track finished playing)
|
||||
pub async fn submit_listen(
|
||||
&self,
|
||||
artist: &str,
|
||||
track: &str,
|
||||
album: Option<&str>,
|
||||
timestamp: i64,
|
||||
additional_info: Option<AdditionalInfo>,
|
||||
) -> Result<(), String> {
|
||||
let token = {
|
||||
let config = self.config.lock().await;
|
||||
if !config.enabled {
|
||||
return Ok(()); // Silently skip if disabled
|
||||
}
|
||||
config.token.clone()
|
||||
};
|
||||
|
||||
let token = token.ok_or("Not authenticated with ListenBrainz")?;
|
||||
|
||||
let mut info = additional_info.unwrap_or_default();
|
||||
// Ensure QBZ identifiers are set
|
||||
let version = env!("CARGO_PKG_VERSION").to_string();
|
||||
info.media_player = "QBZ".to_string();
|
||||
info.media_player_version = version.clone();
|
||||
info.submission_client = "QBZ".to_string();
|
||||
info.submission_client_version = version;
|
||||
|
||||
let payload = SubmitListensPayload {
|
||||
listen_type: ListenType::Single,
|
||||
payload: vec![Listen {
|
||||
listened_at: Some(timestamp),
|
||||
track_metadata: TrackMetadata {
|
||||
artist_name: artist.to_string(),
|
||||
track_name: track.to_string(),
|
||||
release_name: album.map(|s| s.to_string()),
|
||||
additional_info: Some(info),
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
self.submit_listens(&token, &payload).await
|
||||
}
|
||||
|
||||
/// Internal: Submit listens to API
|
||||
async fn submit_listens(&self, token: &str, payload: &SubmitListensPayload) -> Result<(), String> {
|
||||
let url = format!("{}/submit-listens", LISTENBRAINZ_API_URL);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Token {}", token))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to submit listen: {}", e))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let listen_type = match payload.listen_type {
|
||||
ListenType::PlayingNow => "now playing",
|
||||
ListenType::Single => "scrobble",
|
||||
};
|
||||
if let Some(listen) = payload.payload.first() {
|
||||
log::debug!(
|
||||
"ListenBrainz {}: {} - {}",
|
||||
listen_type,
|
||||
listen.track_metadata.artist_name,
|
||||
listen.track_metadata.track_name
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
Err(format!("ListenBrainz submission failed: {} - {}", status, text))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! ListenBrainz integration module
|
||||
//!
|
||||
//! Provides scrobbling and now-playing notifications to ListenBrainz.
|
||||
//! Uses personal user tokens (not OAuth) for authentication.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! - `client.rs`: HTTP client for ListenBrainz API
|
||||
//! - `models.rs`: Submission payload types
|
||||
//! - `cache.rs`: SQLite-based offline queue and token persistence
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! The module is accessed via Tauri commands in `commands/listenbrainz.rs`.
|
||||
//!
|
||||
//! ## Scrobbling Rules
|
||||
//!
|
||||
//! - Now Playing: Submitted when track starts
|
||||
//! - Scrobble: Submitted after 50% of track OR 4 minutes played
|
||||
//! - Skip: No submission if < 30 seconds played
|
||||
//!
|
||||
//! ## MusicBrainz Integration
|
||||
//!
|
||||
//! Uses Stage 1 MusicBrainz data to enrich scrobbles with:
|
||||
//! - Recording MBID
|
||||
//! - Release MBID
|
||||
//! - Artist MBIDs
|
||||
//! - ISRC codes
|
||||
|
||||
pub mod cache;
|
||||
pub mod client;
|
||||
pub mod models;
|
||||
|
||||
pub use cache::{ListenBrainzCache, ListenBrainzCacheState, QueueStats};
|
||||
pub use client::{ListenBrainzClient, ListenBrainzConfig};
|
||||
pub use models::{
|
||||
AdditionalInfo, Listen, ListenBrainzStatus, ListenType, QueuedListen,
|
||||
SubmitListensPayload, TrackMetadata, UserInfo,
|
||||
};
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Shared state wrapper for Tauri
|
||||
pub struct ListenBrainzSharedState {
|
||||
pub client: Arc<Mutex<ListenBrainzClient>>,
|
||||
pub cache: Arc<Mutex<ListenBrainzCache>>,
|
||||
}
|
||||
|
||||
impl ListenBrainzSharedState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let cache_state = ListenBrainzCacheState::new()?;
|
||||
|
||||
// Check for saved credentials and enabled state
|
||||
let (token, user_name, enabled) = {
|
||||
let cache = cache_state.cache.blocking_lock();
|
||||
let (token, user_name) = cache.get_credentials().unwrap_or((None, None));
|
||||
let enabled = cache.is_enabled().unwrap_or(true);
|
||||
(token, user_name, enabled)
|
||||
};
|
||||
|
||||
// Create client with saved config
|
||||
let config = ListenBrainzConfig {
|
||||
enabled,
|
||||
token: token.clone(),
|
||||
user_name: user_name.clone(),
|
||||
};
|
||||
let client = ListenBrainzClient::with_config(config);
|
||||
|
||||
if token.is_some() && user_name.is_some() {
|
||||
log::info!("ListenBrainz: restored session for user {:?}", user_name);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
client: Arc::new(Mutex::new(client)),
|
||||
cache: cache_state.cache,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! ListenBrainz API models
|
||||
//!
|
||||
//! Types for ListenBrainz submission payloads and responses
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Listen type for submission
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ListenType {
|
||||
/// Currently playing track
|
||||
PlayingNow,
|
||||
/// Single scrobble
|
||||
Single,
|
||||
}
|
||||
|
||||
/// A single listen submission
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Listen {
|
||||
/// Unix timestamp (omit for playing_now)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub listened_at: Option<i64>,
|
||||
/// Track metadata
|
||||
pub track_metadata: TrackMetadata,
|
||||
}
|
||||
|
||||
/// Track metadata for a listen
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TrackMetadata {
|
||||
/// Artist name (required)
|
||||
pub artist_name: String,
|
||||
/// Track name (required)
|
||||
pub track_name: String,
|
||||
/// Release/album name (optional)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub release_name: Option<String>,
|
||||
/// Additional metadata
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub additional_info: Option<AdditionalInfo>,
|
||||
}
|
||||
|
||||
/// Additional track info for richer scrobbles
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AdditionalInfo {
|
||||
/// MusicBrainz recording ID (from Stage 1)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub recording_mbid: Option<String>,
|
||||
/// MusicBrainz release ID (from Stage 1)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub release_mbid: Option<String>,
|
||||
/// MusicBrainz artist IDs (from Stage 1)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub artist_mbids: Option<Vec<String>>,
|
||||
/// ISRC code
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub isrc: Option<String>,
|
||||
/// Track duration in milliseconds
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration_ms: Option<u64>,
|
||||
/// Track number on release
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tracknumber: Option<u32>,
|
||||
/// Media player name
|
||||
pub media_player: String,
|
||||
/// Media player version
|
||||
pub media_player_version: String,
|
||||
/// Submission client name
|
||||
pub submission_client: String,
|
||||
/// Submission client version
|
||||
pub submission_client_version: String,
|
||||
}
|
||||
|
||||
impl AdditionalInfo {
|
||||
/// Create new AdditionalInfo with QBZ identifiers
|
||||
pub fn new() -> Self {
|
||||
let version = env!("CARGO_PKG_VERSION").to_string();
|
||||
Self {
|
||||
recording_mbid: None,
|
||||
release_mbid: None,
|
||||
artist_mbids: None,
|
||||
isrc: None,
|
||||
duration_ms: None,
|
||||
tracknumber: None,
|
||||
media_player: "QBZ".to_string(),
|
||||
media_player_version: version.clone(),
|
||||
submission_client: "QBZ".to_string(),
|
||||
submission_client_version: version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AdditionalInfo {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Payload for submitting listens
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SubmitListensPayload {
|
||||
pub listen_type: ListenType,
|
||||
pub payload: Vec<Listen>,
|
||||
}
|
||||
|
||||
/// Response from ListenBrainz API
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListenBrainzResponse {
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub code: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// User info response
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct UserInfo {
|
||||
pub user_name: String,
|
||||
}
|
||||
|
||||
/// Token validation response
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TokenValidationResponse {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
pub valid: bool,
|
||||
#[serde(default)]
|
||||
pub user_name: Option<String>,
|
||||
}
|
||||
|
||||
/// ListenBrainz connection status
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ListenBrainzStatus {
|
||||
pub connected: bool,
|
||||
pub user_name: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Queued listen for offline submission
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QueuedListen {
|
||||
pub id: i64,
|
||||
pub listened_at: i64,
|
||||
pub artist_name: String,
|
||||
pub track_name: String,
|
||||
pub release_name: Option<String>,
|
||||
pub recording_mbid: Option<String>,
|
||||
pub release_mbid: Option<String>,
|
||||
pub artist_mbids: Option<Vec<String>>,
|
||||
pub isrc: Option<String>,
|
||||
pub duration_ms: Option<u64>,
|
||||
pub created_at: i64,
|
||||
pub attempts: i32,
|
||||
pub sent: bool,
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
//! MusicBrainz cache layer
|
||||
//!
|
||||
//! SQLite-based cache for MusicBrainz lookups with TTL expiration
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::models::*;
|
||||
|
||||
/// TTL for recording cache (30 days)
|
||||
const RECORDING_TTL_SECS: i64 = 30 * 24 * 60 * 60;
|
||||
|
||||
/// TTL for artist cache (7 days)
|
||||
const ARTIST_TTL_SECS: i64 = 7 * 24 * 60 * 60;
|
||||
|
||||
/// TTL for release cache (30 days)
|
||||
const RELEASE_TTL_SECS: i64 = 30 * 24 * 60 * 60;
|
||||
|
||||
/// TTL for artist relationships cache (7 days)
|
||||
const RELATIONS_TTL_SECS: i64 = 7 * 24 * 60 * 60;
|
||||
|
||||
/// MusicBrainz cache state shared across commands
|
||||
pub struct MusicBrainzCacheState {
|
||||
pub cache: Arc<Mutex<MusicBrainzCache>>,
|
||||
}
|
||||
|
||||
impl MusicBrainzCacheState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let data_dir = dirs::data_dir()
|
||||
.ok_or("Could not determine data directory")?
|
||||
.join("qbz")
|
||||
.join("cache");
|
||||
|
||||
std::fs::create_dir_all(&data_dir)
|
||||
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
|
||||
|
||||
let db_path = data_dir.join("musicbrainz_cache.db");
|
||||
let cache = MusicBrainzCache::new(&db_path)?;
|
||||
|
||||
log::info!("MusicBrainz cache initialized at {:?}", db_path);
|
||||
|
||||
Ok(Self {
|
||||
cache: Arc::new(Mutex::new(cache)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// MusicBrainz SQLite cache
|
||||
pub struct MusicBrainzCache {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl MusicBrainzCache {
|
||||
pub fn new(path: &Path) -> Result<Self, String> {
|
||||
let conn = Connection::open(path)
|
||||
.map_err(|e| format!("Failed to open MusicBrainz cache: {}", e))?;
|
||||
|
||||
// Enable WAL mode for better concurrency
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
.map_err(|e| format!("Failed to set pragmas: {}", e))?;
|
||||
|
||||
let cache = Self { conn };
|
||||
cache.init()?;
|
||||
Ok(cache)
|
||||
}
|
||||
|
||||
fn init(&self) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute_batch(
|
||||
r#"
|
||||
-- Recordings indexed by ISRC
|
||||
CREATE TABLE IF NOT EXISTS mb_recordings (
|
||||
isrc TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mb_recordings_fetched ON mb_recordings(fetched_at);
|
||||
|
||||
-- Artists indexed by normalized name
|
||||
CREATE TABLE IF NOT EXISTS mb_artists (
|
||||
name_normalized TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mb_artists_fetched ON mb_artists(fetched_at);
|
||||
|
||||
-- Releases indexed by UPC/barcode
|
||||
CREATE TABLE IF NOT EXISTS mb_releases (
|
||||
barcode TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mb_releases_fetched ON mb_releases(fetched_at);
|
||||
|
||||
-- Artist relationships indexed by MBID
|
||||
CREATE TABLE IF NOT EXISTS mb_artist_relations (
|
||||
mbid TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mb_relations_fetched ON mb_artist_relations(fetched_at);
|
||||
"#,
|
||||
)
|
||||
.map_err(|e| format!("Failed to initialize MusicBrainz cache: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn current_timestamp() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Normalize artist name for consistent cache keys
|
||||
pub fn normalize_name(name: &str) -> String {
|
||||
name.to_lowercase()
|
||||
.trim()
|
||||
.replace(['\'', '"', '.', ','], "")
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
// ============ Recording Cache ============
|
||||
|
||||
/// Get cached recording by ISRC
|
||||
pub fn get_recording(&self, isrc: &str) -> Result<Option<ResolvedTrack>, String> {
|
||||
let min_fetched_at = Self::current_timestamp() - RECORDING_TTL_SECS;
|
||||
|
||||
let result: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT data FROM mb_recordings WHERE isrc = ? AND fetched_at > ?",
|
||||
params![isrc, min_fetched_at],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| format!("Failed to query recording cache: {}", e))?;
|
||||
|
||||
if let Some(data) = result {
|
||||
serde_json::from_str(&data)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("Failed to parse cached recording: {}", e))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache a resolved recording
|
||||
pub fn set_recording(&self, isrc: &str, data: &ResolvedTrack) -> Result<(), String> {
|
||||
let fetched_at = Self::current_timestamp();
|
||||
let json = serde_json::to_string(data)
|
||||
.map_err(|e| format!("Failed to serialize recording: {}", e))?;
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO mb_recordings (isrc, data, fetched_at) VALUES (?, ?, ?)",
|
||||
params![isrc, json, fetched_at],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cache recording: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Artist Cache ============
|
||||
|
||||
/// Get cached artist by name
|
||||
pub fn get_artist(&self, name: &str) -> Result<Option<ResolvedArtist>, String> {
|
||||
let normalized = Self::normalize_name(name);
|
||||
let min_fetched_at = Self::current_timestamp() - ARTIST_TTL_SECS;
|
||||
|
||||
let result: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT data FROM mb_artists WHERE name_normalized = ? AND fetched_at > ?",
|
||||
params![normalized, min_fetched_at],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| format!("Failed to query artist cache: {}", e))?;
|
||||
|
||||
if let Some(data) = result {
|
||||
serde_json::from_str(&data)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("Failed to parse cached artist: {}", e))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache a resolved artist
|
||||
pub fn set_artist(&self, name: &str, data: &ResolvedArtist) -> Result<(), String> {
|
||||
let normalized = Self::normalize_name(name);
|
||||
let fetched_at = Self::current_timestamp();
|
||||
let json = serde_json::to_string(data)
|
||||
.map_err(|e| format!("Failed to serialize artist: {}", e))?;
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO mb_artists (name_normalized, data, fetched_at) VALUES (?, ?, ?)",
|
||||
params![normalized, json, fetched_at],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cache artist: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Release Cache ============
|
||||
|
||||
/// Get cached release by barcode
|
||||
pub fn get_release(&self, barcode: &str) -> Result<Option<ResolvedRelease>, String> {
|
||||
let min_fetched_at = Self::current_timestamp() - RELEASE_TTL_SECS;
|
||||
|
||||
let result: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT data FROM mb_releases WHERE barcode = ? AND fetched_at > ?",
|
||||
params![barcode, min_fetched_at],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| format!("Failed to query release cache: {}", e))?;
|
||||
|
||||
if let Some(data) = result {
|
||||
serde_json::from_str(&data)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("Failed to parse cached release: {}", e))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache a resolved release
|
||||
pub fn set_release(&self, barcode: &str, data: &ResolvedRelease) -> Result<(), String> {
|
||||
let fetched_at = Self::current_timestamp();
|
||||
let json = serde_json::to_string(data)
|
||||
.map_err(|e| format!("Failed to serialize release: {}", e))?;
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO mb_releases (barcode, data, fetched_at) VALUES (?, ?, ?)",
|
||||
params![barcode, json, fetched_at],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cache release: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Artist Relations Cache ============
|
||||
|
||||
/// Get cached artist relationships by MBID
|
||||
pub fn get_artist_relations(&self, mbid: &str) -> Result<Option<ArtistRelationships>, String> {
|
||||
let min_fetched_at = Self::current_timestamp() - RELATIONS_TTL_SECS;
|
||||
|
||||
let result: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT data FROM mb_artist_relations WHERE mbid = ? AND fetched_at > ?",
|
||||
params![mbid, min_fetched_at],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| format!("Failed to query relations cache: {}", e))?;
|
||||
|
||||
if let Some(data) = result {
|
||||
serde_json::from_str(&data)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("Failed to parse cached relations: {}", e))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache artist relationships
|
||||
pub fn set_artist_relations(&self, mbid: &str, data: &ArtistRelationships) -> Result<(), String> {
|
||||
let fetched_at = Self::current_timestamp();
|
||||
let json = serde_json::to_string(data)
|
||||
.map_err(|e| format!("Failed to serialize relations: {}", e))?;
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO mb_artist_relations (mbid, data, fetched_at) VALUES (?, ?, ?)",
|
||||
params![mbid, json, fetched_at],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cache relations: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Maintenance ============
|
||||
|
||||
/// Clear expired entries from all tables
|
||||
pub fn cleanup_expired(&self) -> Result<usize, String> {
|
||||
let now = Self::current_timestamp();
|
||||
let mut total_deleted = 0;
|
||||
|
||||
total_deleted += self
|
||||
.conn
|
||||
.execute(
|
||||
"DELETE FROM mb_recordings WHERE fetched_at <= ?",
|
||||
params![now - RECORDING_TTL_SECS],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cleanup recordings: {}", e))?;
|
||||
|
||||
total_deleted += self
|
||||
.conn
|
||||
.execute(
|
||||
"DELETE FROM mb_artists WHERE fetched_at <= ?",
|
||||
params![now - ARTIST_TTL_SECS],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cleanup artists: {}", e))?;
|
||||
|
||||
total_deleted += self
|
||||
.conn
|
||||
.execute(
|
||||
"DELETE FROM mb_releases WHERE fetched_at <= ?",
|
||||
params![now - RELEASE_TTL_SECS],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cleanup releases: {}", e))?;
|
||||
|
||||
total_deleted += self
|
||||
.conn
|
||||
.execute(
|
||||
"DELETE FROM mb_artist_relations WHERE fetched_at <= ?",
|
||||
params![now - RELATIONS_TTL_SECS],
|
||||
)
|
||||
.map_err(|e| format!("Failed to cleanup relations: {}", e))?;
|
||||
|
||||
if total_deleted > 0 {
|
||||
log::info!("MusicBrainz cache cleanup: removed {} expired entries", total_deleted);
|
||||
}
|
||||
|
||||
Ok(total_deleted)
|
||||
}
|
||||
|
||||
/// Clear all cached data
|
||||
pub fn clear_all(&self) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute_batch(
|
||||
r#"
|
||||
DELETE FROM mb_recordings;
|
||||
DELETE FROM mb_artists;
|
||||
DELETE FROM mb_releases;
|
||||
DELETE FROM mb_artist_relations;
|
||||
"#,
|
||||
)
|
||||
.map_err(|e| format!("Failed to clear MusicBrainz cache: {}", e))?;
|
||||
|
||||
log::info!("MusicBrainz cache cleared");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get cache statistics
|
||||
pub fn get_stats(&self) -> Result<CacheStats, String> {
|
||||
let recordings: i64 = self
|
||||
.conn
|
||||
.query_row("SELECT COUNT(*) FROM mb_recordings", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
let artists: i64 = self
|
||||
.conn
|
||||
.query_row("SELECT COUNT(*) FROM mb_artists", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
let releases: i64 = self
|
||||
.conn
|
||||
.query_row("SELECT COUNT(*) FROM mb_releases", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
let relations: i64 = self
|
||||
.conn
|
||||
.query_row("SELECT COUNT(*) FROM mb_artist_relations", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(CacheStats {
|
||||
recordings: recordings as u64,
|
||||
artists: artists as u64,
|
||||
releases: releases as u64,
|
||||
relations: relations as u64,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache statistics
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct CacheStats {
|
||||
pub recordings: u64,
|
||||
pub artists: u64,
|
||||
pub releases: u64,
|
||||
pub relations: u64,
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
//! MusicBrainz API client
|
||||
//!
|
||||
//! HTTP client with rate limiting and proper User-Agent handling
|
||||
//! Uses Cloudflare Workers proxy for consistent rate limiting
|
||||
|
||||
use reqwest::Client;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::models::*;
|
||||
|
||||
/// Proxy URL for MusicBrainz requests
|
||||
const MUSICBRAINZ_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/musicbrainz";
|
||||
|
||||
/// Direct MusicBrainz API URL (fallback)
|
||||
const MUSICBRAINZ_API_URL: &str = "https://musicbrainz.org/ws/2";
|
||||
|
||||
/// Rate limiter for MusicBrainz API (1 request per second)
|
||||
pub struct RateLimiter {
|
||||
last_request: Mutex<Instant>,
|
||||
min_interval: Duration,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
// Start in the past so first request doesn't wait
|
||||
last_request: Mutex::new(Instant::now() - Duration::from_secs(2)),
|
||||
min_interval: Duration::from_millis(1100), // Slightly over 1 second for safety
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn wait(&self) {
|
||||
let mut last = self.last_request.lock().await;
|
||||
let elapsed = last.elapsed();
|
||||
if elapsed < self.min_interval {
|
||||
tokio::time::sleep(self.min_interval - elapsed).await;
|
||||
}
|
||||
*last = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RateLimiter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// MusicBrainz API client configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MusicBrainzConfig {
|
||||
/// Whether MusicBrainz integration is enabled
|
||||
pub enabled: bool,
|
||||
/// Use proxy instead of direct API
|
||||
pub use_proxy: bool,
|
||||
}
|
||||
|
||||
impl Default for MusicBrainzConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
use_proxy: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MusicBrainz API client
|
||||
pub struct MusicBrainzClient {
|
||||
client: Client,
|
||||
rate_limiter: Arc<RateLimiter>,
|
||||
config: Arc<Mutex<MusicBrainzConfig>>,
|
||||
}
|
||||
|
||||
impl Default for MusicBrainzClient {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MusicBrainzClient {
|
||||
pub fn new() -> Self {
|
||||
Self::with_config(MusicBrainzConfig::default())
|
||||
}
|
||||
|
||||
pub fn with_config(config: MusicBrainzConfig) -> Self {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let user_agent = format!(
|
||||
"QBZ/{} (https://github.com/vicrodh/qbz; qbz@vicrodh.dev)",
|
||||
version
|
||||
);
|
||||
|
||||
let client = Client::builder()
|
||||
.user_agent(&user_agent)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
|
||||
Self {
|
||||
client,
|
||||
rate_limiter: Arc::new(RateLimiter::new()),
|
||||
config: Arc::new(Mutex::new(config)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if MusicBrainz integration is enabled
|
||||
pub async fn is_enabled(&self) -> bool {
|
||||
self.config.lock().await.enabled
|
||||
}
|
||||
|
||||
/// Enable or disable MusicBrainz integration
|
||||
pub async fn set_enabled(&self, enabled: bool) {
|
||||
self.config.lock().await.enabled = enabled;
|
||||
}
|
||||
|
||||
/// Get the base URL based on configuration
|
||||
async fn base_url(&self) -> &'static str {
|
||||
if self.config.lock().await.use_proxy {
|
||||
MUSICBRAINZ_PROXY_URL
|
||||
} else {
|
||||
MUSICBRAINZ_API_URL
|
||||
}
|
||||
}
|
||||
|
||||
/// Search recordings by ISRC
|
||||
pub async fn search_recording_by_isrc(&self, isrc: &str) -> Result<RecordingSearchResponse, 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;
|
||||
let url = format!(
|
||||
"{}/recording?query=isrc:{}&fmt=json&limit=5",
|
||||
base_url, isrc
|
||||
);
|
||||
|
||||
log::debug!("MusicBrainz recording search by ISRC: {}", isrc);
|
||||
|
||||
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::<RecordingSearchResponse>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse MusicBrainz response: {}", e))
|
||||
}
|
||||
|
||||
/// Search recordings by title and artist
|
||||
pub async fn search_recording(
|
||||
&self,
|
||||
title: &str,
|
||||
artist: &str,
|
||||
) -> Result<RecordingSearchResponse, 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;
|
||||
let query = format!(
|
||||
"recording:\"{}\" AND artist:\"{}\"",
|
||||
Self::escape_query(title),
|
||||
Self::escape_query(artist)
|
||||
);
|
||||
let url = format!(
|
||||
"{}/recording?query={}&fmt=json&limit=5",
|
||||
base_url,
|
||||
urlencoding::encode(&query)
|
||||
);
|
||||
|
||||
log::debug!("MusicBrainz recording search: {} - {}", artist, title);
|
||||
|
||||
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::<RecordingSearchResponse>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse MusicBrainz response: {}", e))
|
||||
}
|
||||
|
||||
/// Search artists by name
|
||||
pub async fn search_artist(&self, name: &str) -> Result<ArtistSearchResponse, 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;
|
||||
let query = format!("artist:\"{}\"", Self::escape_query(name));
|
||||
let url = format!(
|
||||
"{}/artist?query={}&fmt=json&limit=5",
|
||||
base_url,
|
||||
urlencoding::encode(&query)
|
||||
);
|
||||
|
||||
log::debug!("MusicBrainz artist search: {}", name);
|
||||
|
||||
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::<ArtistSearchResponse>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse MusicBrainz response: {}", e))
|
||||
}
|
||||
|
||||
/// Get artist details with relationships
|
||||
pub async fn get_artist_with_relations(&self, mbid: &str) -> Result<ArtistFullResponse, 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;
|
||||
let url = format!(
|
||||
"{}/artist/{}?inc=artist-rels&fmt=json",
|
||||
base_url, mbid
|
||||
);
|
||||
|
||||
log::debug!("MusicBrainz artist lookup with relations: {}", mbid);
|
||||
|
||||
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::<ArtistFullResponse>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse MusicBrainz response: {}", e))
|
||||
}
|
||||
|
||||
/// Search releases by barcode (UPC/EAN)
|
||||
pub async fn search_release_by_barcode(&self, barcode: &str) -> Result<ReleaseSearchResponse, 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;
|
||||
let url = format!(
|
||||
"{}/release?query=barcode:{}&fmt=json&limit=5",
|
||||
base_url, barcode
|
||||
);
|
||||
|
||||
log::debug!("MusicBrainz release search by barcode: {}", barcode);
|
||||
|
||||
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::<ReleaseSearchResponse>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse MusicBrainz response: {}", e))
|
||||
}
|
||||
|
||||
/// Search releases by title and artist
|
||||
pub async fn search_release(
|
||||
&self,
|
||||
title: &str,
|
||||
artist: &str,
|
||||
) -> Result<ReleaseSearchResponse, 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;
|
||||
let query = format!(
|
||||
"release:\"{}\" AND artist:\"{}\"",
|
||||
Self::escape_query(title),
|
||||
Self::escape_query(artist)
|
||||
);
|
||||
let url = format!(
|
||||
"{}/release?query={}&fmt=json&limit=5",
|
||||
base_url,
|
||||
urlencoding::encode(&query)
|
||||
);
|
||||
|
||||
log::debug!("MusicBrainz release search: {} - {}", artist, title);
|
||||
|
||||
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::<ReleaseSearchResponse>()
|
||||
.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('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace(':', "\\:")
|
||||
.replace('(', "\\(")
|
||||
.replace(')', "\\)")
|
||||
.replace('[', "\\[")
|
||||
.replace(']', "\\]")
|
||||
.replace('{', "\\{")
|
||||
.replace('}', "\\}")
|
||||
.replace('^', "\\^")
|
||||
.replace('~', "\\~")
|
||||
.replace('*', "\\*")
|
||||
.replace('?', "\\?")
|
||||
.replace('!', "\\!")
|
||||
.replace('+', "\\+")
|
||||
.replace('-', "\\-")
|
||||
.replace('&', "\\&")
|
||||
.replace('|', "\\|")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! MusicBrainz integration module
|
||||
//!
|
||||
//! Provides entity resolution and caching for MusicBrainz lookups.
|
||||
//! This module operates as a background semantic engine - it enriches
|
||||
//! data without affecting the UI directly (Stage 1).
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! - `client.rs`: HTTP client with rate limiting (1 req/sec)
|
||||
//! - `models.rs`: API response types and resolved entity types
|
||||
//! - `cache.rs`: SQLite-based cache with TTL expiration
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! The module is accessed via Tauri commands in `commands/musicbrainz.rs`.
|
||||
|
||||
pub mod cache;
|
||||
pub mod client;
|
||||
pub mod models;
|
||||
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,
|
||||
};
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Shared state wrapper for Tauri
|
||||
pub struct MusicBrainzSharedState {
|
||||
pub client: Arc<MusicBrainzClient>,
|
||||
pub cache: Arc<Mutex<MusicBrainzCache>>,
|
||||
}
|
||||
|
||||
impl MusicBrainzSharedState {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let cache_state = MusicBrainzCacheState::new()?;
|
||||
Ok(Self {
|
||||
client: Arc::new(MusicBrainzClient::new()),
|
||||
cache: cache_state.cache,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
//! MusicBrainz API response models
|
||||
//!
|
||||
//! Types for deserializing MusicBrainz JSON responses
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Match confidence levels for MusicBrainz lookups
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MatchConfidence {
|
||||
Exact, // ISRC/UPC exact match
|
||||
High, // Score >= 95
|
||||
Medium, // Score >= 80
|
||||
Low, // Score >= 60
|
||||
None, // No match found
|
||||
}
|
||||
|
||||
impl MatchConfidence {
|
||||
pub fn from_score(score: Option<i32>) -> Self {
|
||||
match score {
|
||||
Some(s) if s >= 100 => Self::Exact,
|
||||
Some(s) if s >= 95 => Self::High,
|
||||
Some(s) if s >= 80 => Self::Medium,
|
||||
Some(s) if s >= 60 => Self::Low,
|
||||
_ => Self::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Artist type classification
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ArtistType {
|
||||
Person,
|
||||
Group,
|
||||
Orchestra,
|
||||
Choir,
|
||||
Character,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Default for ArtistType {
|
||||
fn default() -> Self {
|
||||
Self::Other
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Option<&str>> for ArtistType {
|
||||
fn from(s: Option<&str>) -> Self {
|
||||
match s.map(|s| s.to_lowercase()).as_deref() {
|
||||
Some("person") => Self::Person,
|
||||
Some("group") => Self::Group,
|
||||
Some("orchestra") => Self::Orchestra,
|
||||
Some("choir") => Self::Choir,
|
||||
Some("character") => Self::Character,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ API Response Types ============
|
||||
|
||||
/// Recording search response
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RecordingSearchResponse {
|
||||
pub created: Option<String>,
|
||||
pub count: i32,
|
||||
pub offset: i32,
|
||||
pub recordings: Vec<RecordingResult>,
|
||||
}
|
||||
|
||||
/// Single recording in search results
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RecordingResult {
|
||||
pub id: String,
|
||||
pub score: Option<i32>,
|
||||
pub title: Option<String>,
|
||||
pub length: Option<i64>,
|
||||
#[serde(rename = "artist-credit")]
|
||||
pub artist_credit: Option<Vec<ArtistCredit>>,
|
||||
pub isrcs: Option<Vec<String>>,
|
||||
pub releases: Option<Vec<ReleaseRef>>,
|
||||
}
|
||||
|
||||
/// Artist credit entry
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ArtistCredit {
|
||||
pub name: Option<String>,
|
||||
pub joinphrase: Option<String>,
|
||||
pub artist: ArtistRef,
|
||||
}
|
||||
|
||||
/// Reference to an artist
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ArtistRef {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "sort-name")]
|
||||
pub sort_name: Option<String>,
|
||||
pub disambiguation: Option<String>,
|
||||
}
|
||||
|
||||
/// Reference to a release (album)
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReleaseRef {
|
||||
pub id: String,
|
||||
pub title: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub date: Option<String>,
|
||||
pub country: Option<String>,
|
||||
#[serde(rename = "release-group")]
|
||||
pub release_group: Option<ReleaseGroupRef>,
|
||||
}
|
||||
|
||||
/// Reference to a release group
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReleaseGroupRef {
|
||||
pub id: String,
|
||||
#[serde(rename = "primary-type")]
|
||||
pub primary_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Artist search response
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ArtistSearchResponse {
|
||||
pub created: Option<String>,
|
||||
pub count: i32,
|
||||
pub offset: i32,
|
||||
pub artists: Vec<ArtistResult>,
|
||||
}
|
||||
|
||||
/// Single artist in search results
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ArtistResult {
|
||||
pub id: String,
|
||||
pub score: Option<i32>,
|
||||
pub name: String,
|
||||
#[serde(rename = "sort-name")]
|
||||
pub sort_name: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub artist_type: Option<String>,
|
||||
pub country: Option<String>,
|
||||
pub disambiguation: Option<String>,
|
||||
pub aliases: Option<Vec<Alias>>,
|
||||
#[serde(rename = "life-span")]
|
||||
pub life_span: Option<LifeSpan>,
|
||||
}
|
||||
|
||||
/// Artist alias
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Alias {
|
||||
pub name: String,
|
||||
#[serde(rename = "sort-name")]
|
||||
pub sort_name: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub alias_type: Option<String>,
|
||||
pub locale: Option<String>,
|
||||
pub primary: Option<bool>,
|
||||
}
|
||||
|
||||
/// Life span for an artist
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct LifeSpan {
|
||||
pub begin: Option<String>,
|
||||
pub end: Option<String>,
|
||||
pub ended: Option<bool>,
|
||||
}
|
||||
|
||||
/// Full artist response (with includes)
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ArtistFullResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "sort-name")]
|
||||
pub sort_name: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub artist_type: Option<String>,
|
||||
pub country: Option<String>,
|
||||
pub disambiguation: Option<String>,
|
||||
#[serde(rename = "life-span")]
|
||||
pub life_span: Option<LifeSpan>,
|
||||
pub relations: Option<Vec<Relation>>,
|
||||
}
|
||||
|
||||
/// Relation between entities
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Relation {
|
||||
#[serde(rename = "type")]
|
||||
pub relation_type: String,
|
||||
#[serde(rename = "type-id")]
|
||||
pub type_id: Option<String>,
|
||||
pub direction: Option<String>,
|
||||
pub begin: Option<String>,
|
||||
pub end: Option<String>,
|
||||
pub ended: Option<bool>,
|
||||
pub attributes: Option<Vec<String>>,
|
||||
pub artist: Option<ArtistRef>,
|
||||
}
|
||||
|
||||
/// Release search response
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReleaseSearchResponse {
|
||||
pub created: Option<String>,
|
||||
pub count: i32,
|
||||
pub offset: i32,
|
||||
pub releases: Vec<ReleaseResult>,
|
||||
}
|
||||
|
||||
/// Single release in search results
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReleaseResult {
|
||||
pub id: String,
|
||||
pub score: Option<i32>,
|
||||
pub title: String,
|
||||
pub status: Option<String>,
|
||||
pub date: Option<String>,
|
||||
pub country: Option<String>,
|
||||
pub barcode: Option<String>,
|
||||
#[serde(rename = "label-info")]
|
||||
pub label_info: Option<Vec<LabelInfo>>,
|
||||
#[serde(rename = "release-group")]
|
||||
pub release_group: Option<ReleaseGroupRef>,
|
||||
#[serde(rename = "artist-credit")]
|
||||
pub artist_credit: Option<Vec<ArtistCredit>>,
|
||||
}
|
||||
|
||||
/// Label information
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LabelInfo {
|
||||
#[serde(rename = "catalog-number")]
|
||||
pub catalog_number: Option<String>,
|
||||
pub label: Option<LabelRef>,
|
||||
}
|
||||
|
||||
/// Reference to a label
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LabelRef {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
// ============ Resolved Types (Output) ============
|
||||
|
||||
/// Resolved track with MusicBrainz data
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResolvedTrack {
|
||||
pub mbid: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub artist_credit: Option<String>,
|
||||
pub artist_mbids: Option<Vec<String>>,
|
||||
pub release_mbid: Option<String>,
|
||||
pub release_title: Option<String>,
|
||||
pub confidence: MatchConfidence,
|
||||
}
|
||||
|
||||
impl ResolvedTrack {
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
mbid: None,
|
||||
title: None,
|
||||
artist_credit: None,
|
||||
artist_mbids: None,
|
||||
release_mbid: None,
|
||||
release_title: None,
|
||||
confidence: MatchConfidence::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved artist with MusicBrainz data
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResolvedArtist {
|
||||
pub mbid: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub sort_name: Option<String>,
|
||||
pub artist_type: Option<ArtistType>,
|
||||
pub country: Option<String>,
|
||||
pub disambiguation: Option<String>,
|
||||
pub confidence: MatchConfidence,
|
||||
}
|
||||
|
||||
impl ResolvedArtist {
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
mbid: None,
|
||||
name: None,
|
||||
sort_name: None,
|
||||
artist_type: None,
|
||||
country: None,
|
||||
disambiguation: None,
|
||||
confidence: MatchConfidence::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved release with MusicBrainz data
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResolvedRelease {
|
||||
pub mbid: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub release_group_mbid: Option<String>,
|
||||
pub label: Option<String>,
|
||||
pub catalog_number: Option<String>,
|
||||
pub country: Option<String>,
|
||||
pub date: Option<String>,
|
||||
pub confidence: MatchConfidence,
|
||||
}
|
||||
|
||||
impl ResolvedRelease {
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
mbid: None,
|
||||
title: None,
|
||||
release_group_mbid: None,
|
||||
label: None,
|
||||
catalog_number: None,
|
||||
country: None,
|
||||
date: None,
|
||||
confidence: MatchConfidence::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Related artist (for relationships)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RelatedArtist {
|
||||
pub mbid: String,
|
||||
pub name: String,
|
||||
pub role: Option<String>,
|
||||
pub period: Option<Period>,
|
||||
pub ended: bool,
|
||||
}
|
||||
|
||||
/// Time period for a relationship
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Period {
|
||||
pub begin: Option<String>,
|
||||
pub end: Option<String>,
|
||||
}
|
||||
|
||||
/// Artist relationships
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ArtistRelationships {
|
||||
pub members: Vec<RelatedArtist>,
|
||||
pub past_members: Vec<RelatedArtist>,
|
||||
pub groups: Vec<RelatedArtist>,
|
||||
pub collaborators: Vec<RelatedArtist>,
|
||||
}
|
||||
|
||||
impl ArtistRelationships {
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
members: Vec::new(),
|
||||
past_members: Vec::new(),
|
||||
groups: Vec::new(),
|
||||
collaborators: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.members.is_empty()
|
||||
&& self.past_members.is_empty()
|
||||
&& self.groups.is_empty()
|
||||
&& self.collaborators.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Musician Types ============
|
||||
|
||||
/// Musician confidence level for MusicBrainz ↔ Qobuz matching
|
||||
///
|
||||
/// This determines what UI is shown when a musician is clicked:
|
||||
/// - Confirmed (3): Navigate to Qobuz Artist Page
|
||||
/// - Contextual (2): Navigate to Musician Page
|
||||
/// - Weak (1): Show Informational Modal only
|
||||
/// - None (0): Show Informational Modal only
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MusicianConfidence {
|
||||
/// Level 3: Dedicated Qobuz artist page exists
|
||||
Confirmed,
|
||||
/// Level 2: Appears in Qobuz credits, no standalone artist page
|
||||
Contextual,
|
||||
/// Level 1: MusicBrainz entity exists, sparse Qobuz results
|
||||
Weak,
|
||||
/// Level 0: No safe match, high risk of incorrect association
|
||||
None,
|
||||
}
|
||||
|
||||
impl MusicianConfidence {
|
||||
pub fn level(&self) -> u8 {
|
||||
match self {
|
||||
Self::Confirmed => 3,
|
||||
Self::Contextual => 2,
|
||||
Self::Weak => 1,
|
||||
Self::None => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MusicianConfidence {
|
||||
fn default() -> Self {
|
||||
Self::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved musician with confidence assessment
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResolvedMusician {
|
||||
/// Musician name (from credits or MusicBrainz)
|
||||
pub name: String,
|
||||
/// Primary role (e.g., "drums", "piano")
|
||||
pub role: String,
|
||||
/// MusicBrainz ID (if resolved)
|
||||
pub mbid: Option<String>,
|
||||
/// Qobuz artist ID (if exists)
|
||||
pub qobuz_artist_id: Option<i64>,
|
||||
/// Confidence level
|
||||
pub confidence: MusicianConfidence,
|
||||
/// Known bands/projects (from MusicBrainz)
|
||||
pub bands: Vec<String>,
|
||||
/// Number of albums the musician appears on (Qobuz)
|
||||
pub appears_on_count: usize,
|
||||
}
|
||||
|
||||
impl ResolvedMusician {
|
||||
pub fn empty(name: String, role: String) -> Self {
|
||||
Self {
|
||||
name,
|
||||
role,
|
||||
mbid: None,
|
||||
qobuz_artist_id: None,
|
||||
confidence: MusicianConfidence::None,
|
||||
bands: Vec::new(),
|
||||
appears_on_count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Album appearance for a musician
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AlbumAppearance {
|
||||
pub album_id: String,
|
||||
pub album_title: String,
|
||||
pub album_artwork: String,
|
||||
pub artist_name: String,
|
||||
pub year: Option<String>,
|
||||
pub role_on_album: String,
|
||||
}
|
||||
|
||||
/// Musician appearances response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MusicianAppearances {
|
||||
pub albums: Vec<AlbumAppearance>,
|
||||
pub total: usize,
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Smart Playlist Generation
|
||||
//!
|
||||
//! Generates playlists based on MusicBrainz artist relationships.
|
||||
//! Uses Stage 1 infrastructure to resolve artists and Stage 3 relationships.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Types of smart playlists that can be generated
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PlaylistRule {
|
||||
/// All solo work by members of a band
|
||||
BandMembers {
|
||||
/// The band's MusicBrainz ID
|
||||
artist_mbid: String,
|
||||
/// Include past members
|
||||
include_past_members: bool,
|
||||
},
|
||||
/// Groups that a solo artist is/was a member of
|
||||
ArtistGroups {
|
||||
/// The artist's MusicBrainz ID
|
||||
artist_mbid: String,
|
||||
},
|
||||
/// Artists who have collaborated with the seed artist
|
||||
CollaboratorNetwork {
|
||||
/// Seed artist's MusicBrainz ID
|
||||
artist_mbid: String,
|
||||
/// How many degrees of separation (1 = direct collaborators only)
|
||||
depth: u8,
|
||||
},
|
||||
}
|
||||
|
||||
/// A generated smart playlist
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GeneratedPlaylist {
|
||||
/// Suggested name for the playlist
|
||||
pub suggested_name: String,
|
||||
/// Description of how it was generated
|
||||
pub description: String,
|
||||
/// The rule used to generate this playlist
|
||||
pub rule: PlaylistRule,
|
||||
/// Artist names included in this playlist
|
||||
pub included_artists: Vec<String>,
|
||||
/// Qobuz track IDs found
|
||||
pub track_ids: Vec<u64>,
|
||||
/// Total tracks found (before any limits)
|
||||
pub total_tracks_found: usize,
|
||||
}
|
||||
|
||||
/// Result of searching for an artist's tracks on Qobuz
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ArtistTracksResult {
|
||||
pub artist_name: String,
|
||||
pub qobuz_artist_id: Option<u64>,
|
||||
pub track_ids: Vec<u64>,
|
||||
}
|
||||
|
||||
/// Configuration for playlist generation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaylistGenerationConfig {
|
||||
/// Maximum tracks per related artist
|
||||
pub max_tracks_per_artist: usize,
|
||||
/// Maximum total tracks in playlist
|
||||
pub max_total_tracks: usize,
|
||||
/// Only include tracks available in user's region
|
||||
pub filter_unavailable: bool,
|
||||
}
|
||||
|
||||
impl Default for PlaylistGenerationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_tracks_per_artist: 10,
|
||||
max_total_tracks: 100,
|
||||
filter_unavailable: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@
|
||||
//! Supports both rodio (PipeWire/Pulse) and direct ALSA (hw: devices).
|
||||
|
||||
mod playback_engine;
|
||||
mod streaming_source;
|
||||
|
||||
pub use streaming_source::{BufferedMediaSource, BufferWriter, StreamingConfig, IncrementalStreamingSource};
|
||||
|
||||
use std::io::{BufReader, Cursor, Read, Seek, SeekFrom};
|
||||
use std::panic::{self, AssertUnwindSafe};
|
||||
@@ -48,6 +51,15 @@ enum AudioCommand {
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
},
|
||||
/// Play from streaming source (BufferedMediaSource)
|
||||
/// The download task should already be running and pushing to the source
|
||||
PlayStreaming {
|
||||
source: Arc<BufferedMediaSource>,
|
||||
track_id: u64,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
duration_secs: u64,
|
||||
},
|
||||
/// Pause playback
|
||||
Pause,
|
||||
/// Resume playback
|
||||
@@ -1046,6 +1058,216 @@ impl Player {
|
||||
actual_duration
|
||||
);
|
||||
}
|
||||
AudioCommand::PlayStreaming { source, track_id, sample_rate, channels, duration_secs } => {
|
||||
log::info!(
|
||||
"Audio thread: starting streaming playback for track {} ({}Hz, {} channels, {}s)",
|
||||
track_id,
|
||||
sample_rate,
|
||||
channels,
|
||||
duration_secs
|
||||
);
|
||||
*pause_suspend_deadline = None;
|
||||
|
||||
// Get DAC passthrough setting
|
||||
let dac_passthrough = thread_settings
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|s| s.dac_passthrough)
|
||||
.unwrap_or(false);
|
||||
|
||||
// Check if we need to recreate the stream
|
||||
let format_changed = *current_sample_rate != Some(sample_rate)
|
||||
|| *current_channels != Some(channels);
|
||||
|
||||
let using_alsa_direct = thread_settings
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|s| s.backend_type)
|
||||
.map(|b| b == AudioBackendType::Alsa)
|
||||
.unwrap_or(false);
|
||||
|
||||
let needs_new_stream = stream_opt.is_none()
|
||||
|| (dac_passthrough && format_changed)
|
||||
|| (using_alsa_direct && format_changed);
|
||||
|
||||
if needs_new_stream {
|
||||
if stream_opt.is_some() {
|
||||
if (dac_passthrough || using_alsa_direct) && format_changed {
|
||||
let mode = if using_alsa_direct { "ALSA Direct" } else { "DAC passthrough" };
|
||||
log::info!(
|
||||
"Streaming: Sample rate/channels changed to {}Hz/{}ch - recreating OutputStream ({})",
|
||||
sample_rate,
|
||||
channels,
|
||||
mode
|
||||
);
|
||||
}
|
||||
drop(stream_opt.take());
|
||||
}
|
||||
|
||||
let stream_result = if let Some(settings) = thread_settings.lock().ok() {
|
||||
match try_init_stream_with_backend(&settings, sample_rate, channels) {
|
||||
Some(result) => result,
|
||||
None => {
|
||||
log::info!("Backend system not configured, using legacy CPAL path");
|
||||
let device = if let Some(ref name) = *current_device_name {
|
||||
host.output_devices()
|
||||
.ok()
|
||||
.and_then(|mut devices| {
|
||||
devices.find(|d| d.name().ok().as_ref() == Some(name))
|
||||
})
|
||||
.or_else(|| host.default_output_device())
|
||||
} else {
|
||||
host.default_output_device()
|
||||
};
|
||||
|
||||
let Some(device) = device else {
|
||||
log::error!("No audio output device available for streaming");
|
||||
thread_state.set_stream_error(true);
|
||||
return;
|
||||
};
|
||||
|
||||
if let Ok(name) = device.name() {
|
||||
thread_state.set_current_device(Some(name));
|
||||
}
|
||||
|
||||
create_output_stream_with_config(
|
||||
&device,
|
||||
sample_rate,
|
||||
channels,
|
||||
dac_passthrough,
|
||||
).map(|(stream, handle)| StreamType::Rodio(stream, handle))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let device = host.default_output_device();
|
||||
let Some(device) = device else {
|
||||
log::error!("No audio output device available for streaming");
|
||||
thread_state.set_stream_error(true);
|
||||
return;
|
||||
};
|
||||
create_output_stream_with_config(&device, sample_rate, channels, dac_passthrough)
|
||||
.map(|(stream, handle)| StreamType::Rodio(stream, handle))
|
||||
};
|
||||
|
||||
match stream_result {
|
||||
Ok(stream) => {
|
||||
*stream_opt = Some(stream);
|
||||
*current_sample_rate = Some(sample_rate);
|
||||
*current_channels = Some(channels);
|
||||
thread_state.set_stream_error(false);
|
||||
log::info!("✅ Streaming audio stream ready at {}Hz", sample_rate);
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("❌ Failed to create stream for streaming at {}Hz: {}", sample_rate, e);
|
||||
thread_state.set_stream_error(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(ref stream) = *stream_opt else {
|
||||
log::error!("Audio thread: no audio device available for streaming");
|
||||
return;
|
||||
};
|
||||
|
||||
// Stop previous engine
|
||||
if let Some(engine) = current_engine.take() {
|
||||
engine.stop();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
// Create PlaybackEngine
|
||||
let mut engine = match stream {
|
||||
StreamType::Rodio(_stream, handle) => {
|
||||
match PlaybackEngine::new_rodio(handle) {
|
||||
Ok(e) => {
|
||||
*consecutive_sink_failures = 0;
|
||||
thread_state.set_stream_error(false);
|
||||
e
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to create engine for streaming: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
StreamType::AlsaDirect(alsa_stream) => {
|
||||
let hardware_volume = thread_settings
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|s| s.alsa_hardware_volume)
|
||||
.unwrap_or(false);
|
||||
PlaybackEngine::new_alsa_direct(alsa_stream.clone(), hardware_volume)
|
||||
}
|
||||
};
|
||||
|
||||
let volume = thread_state.volume.load(Ordering::SeqCst) as f32 / 100.0;
|
||||
engine.set_volume(volume);
|
||||
|
||||
// Wait for minimum buffer before starting playback
|
||||
log::info!("Streaming: waiting for initial buffer...");
|
||||
let start_wait = Instant::now();
|
||||
let max_wait = Duration::from_secs(30);
|
||||
|
||||
while !source.has_min_buffer() && start_wait.elapsed() < max_wait {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
if !source.has_min_buffer() {
|
||||
log::error!("Streaming: timeout waiting for initial buffer");
|
||||
return;
|
||||
}
|
||||
|
||||
let buffer_wait_ms = start_wait.elapsed().as_millis();
|
||||
log::info!(
|
||||
"Streaming: initial buffer ready in {}ms, creating incremental decoder...",
|
||||
buffer_wait_ms
|
||||
);
|
||||
|
||||
// Create incremental streaming source - this starts playback IMMEDIATELY
|
||||
// while continuing to decode/download in background
|
||||
let incremental_source = match IncrementalStreamingSource::new(source.clone()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::error!("Failed to create incremental streaming source: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Verify sample rate/channels match what we expected
|
||||
let actual_sr = incremental_source.get_sample_rate();
|
||||
let actual_ch = incremental_source.get_channels();
|
||||
if actual_sr != sample_rate || actual_ch != channels {
|
||||
log::warn!(
|
||||
"Streaming: detected format {}Hz/{}ch differs from expected {}Hz/{}ch",
|
||||
actual_sr, actual_ch, sample_rate, channels
|
||||
);
|
||||
}
|
||||
|
||||
// Set duration from track metadata (passed from frontend)
|
||||
// This allows the seekbar to show progress even during streaming
|
||||
thread_state.duration.store(duration_secs, Ordering::SeqCst);
|
||||
|
||||
// Box the incremental source to match the expected type
|
||||
let source_to_play: Box<dyn Source<Item = i16> + Send> = Box::new(incremental_source);
|
||||
if let Err(e) = engine.append(source_to_play) {
|
||||
log::error!("Failed to append streaming source to engine: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
thread_state.is_playing.store(true, Ordering::SeqCst);
|
||||
thread_state.position.store(0, Ordering::SeqCst);
|
||||
thread_state.current_track_id.store(track_id, Ordering::SeqCst);
|
||||
thread_state.start_playback_timer(0);
|
||||
|
||||
*current_engine = Some(engine);
|
||||
log::info!(
|
||||
"Audio thread: streaming playback STARTED in {}ms (incremental decode active)",
|
||||
start_wait.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
AudioCommand::Pause => {
|
||||
if let Some(ref engine) = *current_engine {
|
||||
engine.pause();
|
||||
@@ -1438,6 +1660,93 @@ impl Player {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Play from streaming source (starts playback before full download)
|
||||
/// Returns the BufferWriter so caller can push data as it downloads
|
||||
pub fn play_streaming(
|
||||
&self,
|
||||
track_id: u64,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
content_length: u64,
|
||||
buffer_seconds: u8,
|
||||
duration_secs: u64,
|
||||
) -> Result<BufferWriter, String> {
|
||||
log::info!(
|
||||
"Player: Starting streaming playback for track {} ({}Hz, {}ch, {} bytes total, {}s)",
|
||||
track_id,
|
||||
sample_rate,
|
||||
channels,
|
||||
content_length,
|
||||
duration_secs
|
||||
);
|
||||
|
||||
// Use StreamingConfig::from_seconds for proper buffer sizing
|
||||
let config = StreamingConfig::from_seconds(buffer_seconds);
|
||||
|
||||
let (source, writer) = BufferedMediaSource::new(config, Some(content_length));
|
||||
let source = Arc::new(source);
|
||||
|
||||
self.tx
|
||||
.send(AudioCommand::PlayStreaming {
|
||||
source: source.clone(),
|
||||
track_id,
|
||||
sample_rate,
|
||||
channels,
|
||||
duration_secs,
|
||||
})
|
||||
.map_err(|e| {
|
||||
log::error!("Player: Failed to send streaming command: {}", e);
|
||||
format!("Failed to send streaming play command: {}", e)
|
||||
})?;
|
||||
|
||||
log::info!("Player: Streaming playback initiated");
|
||||
Ok(writer)
|
||||
}
|
||||
|
||||
/// Play from streaming source with dynamic buffer based on measured speed
|
||||
/// Returns the BufferWriter so caller can push data as it downloads
|
||||
pub fn play_streaming_dynamic(
|
||||
&self,
|
||||
track_id: u64,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
content_length: u64,
|
||||
speed_mbps: f64,
|
||||
duration_secs: u64,
|
||||
) -> Result<BufferWriter, String> {
|
||||
log::info!(
|
||||
"Player: Starting dynamic streaming for track {} ({}Hz, {}ch, {:.2} MB, {:.1} MB/s, {}s)",
|
||||
track_id,
|
||||
sample_rate,
|
||||
channels,
|
||||
content_length as f64 / (1024.0 * 1024.0),
|
||||
speed_mbps,
|
||||
duration_secs
|
||||
);
|
||||
|
||||
// Use StreamingConfig::from_speed_mbps for dynamic buffer sizing
|
||||
let config = StreamingConfig::from_speed_mbps(speed_mbps);
|
||||
|
||||
let (source, writer) = BufferedMediaSource::new(config, Some(content_length));
|
||||
let source = Arc::new(source);
|
||||
|
||||
self.tx
|
||||
.send(AudioCommand::PlayStreaming {
|
||||
source: source.clone(),
|
||||
track_id,
|
||||
sample_rate,
|
||||
channels,
|
||||
duration_secs,
|
||||
})
|
||||
.map_err(|e| {
|
||||
log::error!("Player: Failed to send streaming command: {}", e);
|
||||
format!("Failed to send streaming play command: {}", e)
|
||||
})?;
|
||||
|
||||
log::info!("Player: Dynamic streaming playback initiated");
|
||||
Ok(writer)
|
||||
}
|
||||
|
||||
/// Download audio from URL with timeout
|
||||
async fn download_audio(&self, url: &str) -> Result<Vec<u8>, String> {
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -23,6 +23,7 @@ pub enum PlaybackEngine {
|
||||
AlsaDirect {
|
||||
stream: Arc<AlsaDirectStream>,
|
||||
is_playing: Arc<AtomicBool>,
|
||||
should_stop: Arc<AtomicBool>, // Separate flag for full stop vs pause
|
||||
position_frames: Arc<AtomicU64>,
|
||||
duration_frames: Arc<AtomicU64>,
|
||||
playback_thread: Option<thread::JoinHandle<()>>,
|
||||
@@ -44,6 +45,7 @@ impl PlaybackEngine {
|
||||
Self::AlsaDirect {
|
||||
stream,
|
||||
is_playing: Arc::new(AtomicBool::new(false)),
|
||||
should_stop: Arc::new(AtomicBool::new(false)),
|
||||
position_frames: Arc::new(AtomicU64::new(0)),
|
||||
duration_frames: Arc::new(AtomicU64::new(0)),
|
||||
playback_thread: None,
|
||||
@@ -64,6 +66,7 @@ impl PlaybackEngine {
|
||||
Self::AlsaDirect {
|
||||
stream,
|
||||
is_playing,
|
||||
should_stop,
|
||||
position_frames,
|
||||
duration_frames,
|
||||
playback_thread,
|
||||
@@ -74,15 +77,18 @@ impl PlaybackEngine {
|
||||
// 2. Converts i16 samples to f32
|
||||
// 3. Writes to ALSA PCM
|
||||
// 4. Tracks position
|
||||
// 5. Supports pause/resume without terminating
|
||||
|
||||
let stream_clone = stream.clone();
|
||||
let is_playing_clone = is_playing.clone();
|
||||
let should_stop_clone = should_stop.clone();
|
||||
let position_clone = position_frames.clone();
|
||||
let duration_clone = duration_frames.clone();
|
||||
|
||||
let channels = stream.channels();
|
||||
|
||||
is_playing.store(true, Ordering::SeqCst);
|
||||
should_stop.store(false, Ordering::SeqCst);
|
||||
position_clone.store(0, Ordering::SeqCst);
|
||||
|
||||
log::info!("[ALSA Direct Engine] Starting streaming playback thread");
|
||||
@@ -98,11 +104,22 @@ impl PlaybackEngine {
|
||||
let mut source_iter = source.into_iter();
|
||||
let mut natural_end = false;
|
||||
|
||||
loop {
|
||||
// Check if we should stop
|
||||
if !is_playing_clone.load(Ordering::SeqCst) {
|
||||
log::info!("[ALSA Direct Engine] Playback paused/stopped");
|
||||
break;
|
||||
'playback: loop {
|
||||
// Check if we should stop completely (not just pause)
|
||||
if should_stop_clone.load(Ordering::SeqCst) {
|
||||
log::info!("[ALSA Direct Engine] Stop requested, terminating thread");
|
||||
break 'playback;
|
||||
}
|
||||
|
||||
// Check if paused - wait instead of terminating
|
||||
while !is_playing_clone.load(Ordering::SeqCst) {
|
||||
// Still check for stop while paused
|
||||
if should_stop_clone.load(Ordering::SeqCst) {
|
||||
log::info!("[ALSA Direct Engine] Stop requested while paused");
|
||||
break 'playback;
|
||||
}
|
||||
// Sleep briefly to avoid busy-waiting
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
// Fill buffer from source
|
||||
@@ -118,13 +135,14 @@ impl PlaybackEngine {
|
||||
// End of stream
|
||||
log::info!("[ALSA Direct Engine] Stream ended (total frames: {})", total_frames);
|
||||
natural_end = true;
|
||||
break;
|
||||
break 'playback;
|
||||
}
|
||||
|
||||
// Write to ALSA (auto-converts based on detected format)
|
||||
// This is bit-perfect: no resampling, no mixing, direct to hardware
|
||||
if let Err(e) = stream_clone.write(&buffer_i16) {
|
||||
log::error!("[ALSA Direct Engine] Write failed: {}", e);
|
||||
break;
|
||||
break 'playback;
|
||||
}
|
||||
|
||||
// Update position
|
||||
@@ -160,6 +178,7 @@ impl PlaybackEngine {
|
||||
match self {
|
||||
Self::Rodio { sink } => sink.play(),
|
||||
Self::AlsaDirect { is_playing, .. } => {
|
||||
log::info!("[ALSA Direct Engine] Resume requested");
|
||||
is_playing.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
@@ -170,6 +189,7 @@ impl PlaybackEngine {
|
||||
match self {
|
||||
Self::Rodio { sink } => sink.pause(),
|
||||
Self::AlsaDirect { is_playing, .. } => {
|
||||
log::info!("[ALSA Direct Engine] Pause requested");
|
||||
is_playing.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
@@ -184,9 +204,13 @@ impl PlaybackEngine {
|
||||
Self::AlsaDirect {
|
||||
stream,
|
||||
is_playing,
|
||||
should_stop,
|
||||
playback_thread,
|
||||
..
|
||||
} => {
|
||||
log::info!("[ALSA Direct Engine] Stop requested");
|
||||
// Signal thread to stop completely
|
||||
should_stop.store(true, Ordering::SeqCst);
|
||||
is_playing.store(false, Ordering::SeqCst);
|
||||
|
||||
// Wait for playback thread to finish
|
||||
|
||||
@@ -0,0 +1,771 @@
|
||||
//! Buffered media source for streaming playback.
|
||||
//!
|
||||
//! Provides two main components:
|
||||
//! 1. `BufferedMediaSource` - Wraps an async HTTP response to provide a synchronous
|
||||
//! `Read + Seek` interface required by symphonia decoders.
|
||||
//! 2. `IncrementalStreamingSource` - A rodio Source that decodes audio packets
|
||||
//! incrementally as they become available, allowing playback to start before
|
||||
//! the entire file is downloaded.
|
||||
//!
|
||||
//! # Design
|
||||
//!
|
||||
//! The source uses a growing buffer that accumulates data from the HTTP response.
|
||||
//! - Reads block if requesting data not yet buffered
|
||||
//! - Seek forward blocks until data is available
|
||||
//! - Seek backward works within buffered data
|
||||
//! - Seek beyond current buffer position blocks until data arrives
|
||||
//!
|
||||
//! # Thread Safety
|
||||
//!
|
||||
//! The buffer state is shared between:
|
||||
//! - The reader (audio thread, synchronous)
|
||||
//! - The writer (download task, async)
|
||||
//!
|
||||
//! Communication uses `Mutex` + `Condvar` for blocking synchronization.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{Error as IoError, ErrorKind, Read, Result as IoResult, Seek, SeekFrom};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use rodio::Source;
|
||||
use symphonia::core::audio::SampleBuffer;
|
||||
use symphonia::core::codecs::{Decoder, DecoderOptions};
|
||||
use symphonia::core::errors::Error as SymphoniaError;
|
||||
use symphonia::core::formats::{FormatOptions, FormatReader};
|
||||
use symphonia::core::io::{MediaSource, MediaSourceStream};
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Hint;
|
||||
use symphonia::default::{get_codecs, get_probe};
|
||||
|
||||
/// Configuration for the streaming buffer
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamingConfig {
|
||||
/// Minimum bytes to buffer before allowing reads (for format detection)
|
||||
pub initial_buffer_bytes: usize,
|
||||
/// Maximum buffer size before backpressure (not enforced, just for info)
|
||||
pub max_buffer_bytes: usize,
|
||||
}
|
||||
|
||||
impl Default for StreamingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// 512KB default - enough for format headers and ~2-5 seconds of audio
|
||||
// This allows playback to start quickly while still having enough
|
||||
// buffer to handle network jitter
|
||||
initial_buffer_bytes: 512 * 1024,
|
||||
// 100MB max buffer
|
||||
max_buffer_bytes: 100 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamingConfig {
|
||||
/// Create config from buffer seconds and approximate bitrate
|
||||
///
|
||||
/// For Hi-Res FLAC at 192kHz/24bit stereo, bitrate is roughly 9.2 Mbps
|
||||
/// We estimate ~1MB per second as a conservative approximation
|
||||
pub fn from_seconds(seconds: u8) -> Self {
|
||||
// Minimum 256KB to ensure format detection works
|
||||
let bytes = ((seconds as usize) * 1024 * 1024).max(256 * 1024);
|
||||
Self {
|
||||
initial_buffer_bytes: bytes,
|
||||
max_buffer_bytes: 100 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a minimal config for fastest startup
|
||||
/// Uses smallest buffer that still allows format detection (~256KB)
|
||||
pub fn fast_start() -> Self {
|
||||
Self {
|
||||
initial_buffer_bytes: 256 * 1024,
|
||||
max_buffer_bytes: 100 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create config dynamically based on measured download speed
|
||||
///
|
||||
/// - Very fast (>10 MB/s): 256KB (instant start)
|
||||
/// - Fast (5-10 MB/s): 384KB
|
||||
/// - Normal (2-5 MB/s): 512KB
|
||||
/// - Slow (1-2 MB/s): 1MB (more buffer to prevent stutter)
|
||||
/// - Very slow (<1 MB/s): 2MB
|
||||
pub fn from_speed_mbps(speed_mbps: f64) -> Self {
|
||||
let initial_buffer = if speed_mbps >= 10.0 {
|
||||
256 * 1024 // 256KB - instant start for very fast connections
|
||||
} else if speed_mbps >= 5.0 {
|
||||
384 * 1024 // 384KB
|
||||
} else if speed_mbps >= 2.0 {
|
||||
512 * 1024 // 512KB - default
|
||||
} else if speed_mbps >= 1.0 {
|
||||
1024 * 1024 // 1MB - more buffer for slower connections
|
||||
} else {
|
||||
2 * 1024 * 1024 // 2MB - maximum buffer for very slow connections
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"📶 Dynamic buffer: {:.1} MB/s detected → {}KB initial buffer",
|
||||
speed_mbps,
|
||||
initial_buffer / 1024
|
||||
);
|
||||
|
||||
Self {
|
||||
initial_buffer_bytes: initial_buffer,
|
||||
max_buffer_bytes: 100 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal state shared between reader and writer
|
||||
struct BufferState {
|
||||
/// Accumulated data from HTTP response
|
||||
data: Vec<u8>,
|
||||
/// True when HTTP download is complete
|
||||
download_complete: bool,
|
||||
/// Error from download, if any
|
||||
download_error: Option<String>,
|
||||
/// Total expected size (from Content-Length), if known
|
||||
total_size: Option<u64>,
|
||||
}
|
||||
|
||||
/// A media source that buffers from an async HTTP stream.
|
||||
///
|
||||
/// Provides `Read + Seek` interface for decoders while data is still downloading.
|
||||
/// The source is created with a `BufferWriter` that receives chunks from the
|
||||
/// download task.
|
||||
pub struct BufferedMediaSource {
|
||||
state: Arc<(Mutex<BufferState>, Condvar)>,
|
||||
config: StreamingConfig,
|
||||
/// Each reader has its own read position
|
||||
read_pos: std::sync::atomic::AtomicU64,
|
||||
}
|
||||
|
||||
impl BufferedMediaSource {
|
||||
/// Create a new buffered source.
|
||||
///
|
||||
/// Returns the source and a writer for pushing downloaded chunks.
|
||||
/// The writer should be used from the async download task.
|
||||
pub fn new(config: StreamingConfig, total_size: Option<u64>) -> (Self, BufferWriter) {
|
||||
let state = Arc::new((
|
||||
Mutex::new(BufferState {
|
||||
data: Vec::with_capacity(config.initial_buffer_bytes),
|
||||
download_complete: false,
|
||||
download_error: None,
|
||||
total_size,
|
||||
}),
|
||||
Condvar::new(),
|
||||
));
|
||||
|
||||
let source = Self {
|
||||
state: Arc::clone(&state),
|
||||
config: config.clone(),
|
||||
read_pos: std::sync::atomic::AtomicU64::new(0),
|
||||
};
|
||||
|
||||
let writer = BufferWriter { state };
|
||||
|
||||
(source, writer)
|
||||
}
|
||||
|
||||
/// Create a new reader that shares the same buffer but has its own read position.
|
||||
/// This is used to pass to symphonia which needs ownership of the reader.
|
||||
pub fn create_reader(&self) -> Self {
|
||||
Self {
|
||||
state: Arc::clone(&self.state),
|
||||
config: self.config.clone(),
|
||||
read_pos: std::sync::atomic::AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait until initial buffer is filled or download completes.
|
||||
///
|
||||
/// This should be called before passing the source to the decoder,
|
||||
/// to ensure enough data is available for format detection.
|
||||
///
|
||||
/// Returns error if download fails before initial buffer is filled.
|
||||
pub fn wait_for_initial_buffer(&self) -> IoResult<()> {
|
||||
let (lock, cvar) = &*self.state;
|
||||
let mut state = lock.lock().map_err(|_| {
|
||||
IoError::new(ErrorKind::Other, "Failed to acquire buffer lock")
|
||||
})?;
|
||||
|
||||
while state.data.len() < self.config.initial_buffer_bytes
|
||||
&& !state.download_complete
|
||||
&& state.download_error.is_none()
|
||||
{
|
||||
state = cvar.wait(state).map_err(|_| {
|
||||
IoError::new(ErrorKind::Other, "Condition variable wait failed")
|
||||
})?;
|
||||
}
|
||||
|
||||
if let Some(ref err) = state.download_error {
|
||||
return Err(IoError::new(ErrorKind::Other, err.clone()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if download is complete (full file in buffer)
|
||||
pub fn is_complete(&self) -> bool {
|
||||
let (lock, _) = &*self.state;
|
||||
if let Ok(state) = lock.lock() {
|
||||
state.download_complete && state.download_error.is_none()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current buffer size in bytes
|
||||
pub fn buffer_size(&self) -> usize {
|
||||
let (lock, _) = &*self.state;
|
||||
if let Ok(state) = lock.lock() {
|
||||
state.data.len()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the complete data if download finished successfully.
|
||||
///
|
||||
/// Used to store in cache after streaming playback completes.
|
||||
/// Returns None if download is not complete or failed.
|
||||
pub fn take_complete_data(&self) -> Option<Vec<u8>> {
|
||||
let (lock, _) = &*self.state;
|
||||
if let Ok(state) = lock.lock() {
|
||||
if state.download_complete && state.download_error.is_none() {
|
||||
Some(state.data.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Get download progress as a fraction (0.0 to 1.0)
|
||||
///
|
||||
/// Returns None if total size is unknown
|
||||
pub fn progress(&self) -> Option<f32> {
|
||||
let (lock, _) = &*self.state;
|
||||
if let Ok(state) = lock.lock() {
|
||||
state.total_size.map(|total| {
|
||||
if total == 0 {
|
||||
1.0
|
||||
} else {
|
||||
state.data.len() as f32 / total as f32
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if minimum buffer for playback is available
|
||||
///
|
||||
/// Returns true when initial_buffer_bytes have been buffered
|
||||
/// or the download is complete.
|
||||
pub fn has_min_buffer(&self) -> bool {
|
||||
let (lock, _) = &*self.state;
|
||||
if let Ok(state) = lock.lock() {
|
||||
state.data.len() >= self.config.initial_buffer_bytes || state.download_complete
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for BufferedMediaSource {
|
||||
fn read(&mut self, buf: &mut [u8]) -> IoResult<usize> {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let (lock, cvar) = &*self.state;
|
||||
let mut state = lock.lock().map_err(|_| {
|
||||
IoError::new(ErrorKind::Other, "Failed to acquire buffer lock")
|
||||
})?;
|
||||
|
||||
let read_pos = self.read_pos.load(Ordering::SeqCst) as usize;
|
||||
|
||||
// Wait for data if we're ahead of buffer
|
||||
while read_pos >= state.data.len()
|
||||
&& !state.download_complete
|
||||
&& state.download_error.is_none()
|
||||
{
|
||||
state = cvar.wait(state).map_err(|_| {
|
||||
IoError::new(ErrorKind::Other, "Condition variable wait failed")
|
||||
})?;
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
if let Some(ref err) = state.download_error {
|
||||
return Err(IoError::new(ErrorKind::Other, err.clone()));
|
||||
}
|
||||
|
||||
// EOF if at end and download complete
|
||||
if read_pos >= state.data.len() && state.download_complete {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Read available data
|
||||
let available = state.data.len() - read_pos;
|
||||
let to_read = buf.len().min(available);
|
||||
buf[..to_read].copy_from_slice(&state.data[read_pos..read_pos + to_read]);
|
||||
self.read_pos.store((read_pos + to_read) as u64, Ordering::SeqCst);
|
||||
|
||||
Ok(to_read)
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for BufferedMediaSource {
|
||||
fn seek(&mut self, pos: SeekFrom) -> IoResult<u64> {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let (lock, cvar) = &*self.state;
|
||||
let mut state = lock.lock().map_err(|_| {
|
||||
IoError::new(ErrorKind::Other, "Failed to acquire buffer lock")
|
||||
})?;
|
||||
|
||||
let current_pos = self.read_pos.load(Ordering::SeqCst) as i64;
|
||||
|
||||
let new_pos = match pos {
|
||||
SeekFrom::Start(offset) => offset as i64,
|
||||
SeekFrom::Current(offset) => current_pos + offset,
|
||||
SeekFrom::End(offset) => {
|
||||
// For End seeks, we need to know total size or have complete download
|
||||
if let Some(total) = state.total_size {
|
||||
total as i64 + offset
|
||||
} else if state.download_complete {
|
||||
state.data.len() as i64 + offset
|
||||
} else {
|
||||
// Can't seek from end without knowing size
|
||||
return Err(IoError::new(
|
||||
ErrorKind::Unsupported,
|
||||
"Cannot seek from end while streaming without known size",
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if new_pos < 0 {
|
||||
return Err(IoError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Seek position before start of stream",
|
||||
));
|
||||
}
|
||||
|
||||
let new_pos_usize = new_pos as usize;
|
||||
|
||||
// If seeking forward beyond buffer, wait for data
|
||||
while new_pos_usize > state.data.len()
|
||||
&& !state.download_complete
|
||||
&& state.download_error.is_none()
|
||||
{
|
||||
state = cvar.wait(state).map_err(|_| {
|
||||
IoError::new(ErrorKind::Other, "Condition variable wait failed")
|
||||
})?;
|
||||
}
|
||||
|
||||
if let Some(ref err) = state.download_error {
|
||||
return Err(IoError::new(ErrorKind::Other, err.clone()));
|
||||
}
|
||||
|
||||
// After download complete, check bounds
|
||||
if state.download_complete && new_pos_usize > state.data.len() {
|
||||
return Err(IoError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Seek position beyond end of stream",
|
||||
));
|
||||
}
|
||||
|
||||
self.read_pos.store(new_pos as u64, Ordering::SeqCst);
|
||||
Ok(new_pos as u64)
|
||||
}
|
||||
}
|
||||
|
||||
// Required for symphonia MediaSource trait
|
||||
impl MediaSource for BufferedMediaSource {
|
||||
fn is_seekable(&self) -> bool {
|
||||
// We support seeking within buffered data
|
||||
true
|
||||
}
|
||||
|
||||
fn byte_len(&self) -> Option<u64> {
|
||||
let (lock, _) = &*self.state;
|
||||
if let Ok(state) = lock.lock() {
|
||||
state.total_size
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writer half for pushing downloaded chunks from the async download task.
|
||||
///
|
||||
/// This is the sender side that receives data from the HTTP response
|
||||
/// and makes it available to the `BufferedMediaSource` reader.
|
||||
#[derive(Clone)]
|
||||
pub struct BufferWriter {
|
||||
state: Arc<(Mutex<BufferState>, Condvar)>,
|
||||
}
|
||||
|
||||
impl BufferWriter {
|
||||
/// Push a chunk of downloaded data
|
||||
///
|
||||
/// This wakes up any readers waiting for data.
|
||||
pub fn push_chunk(&self, chunk: &[u8]) -> Result<(), String> {
|
||||
let (lock, cvar) = &*self.state;
|
||||
let mut state = lock.lock().map_err(|_| "Failed to acquire buffer lock")?;
|
||||
|
||||
state.data.extend_from_slice(chunk);
|
||||
cvar.notify_all();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark download as complete
|
||||
///
|
||||
/// After this is called, readers will receive EOF after reading all buffered data.
|
||||
pub fn complete(&self) -> Result<(), String> {
|
||||
let (lock, cvar) = &*self.state;
|
||||
let mut state = lock.lock().map_err(|_| "Failed to acquire buffer lock")?;
|
||||
|
||||
state.download_complete = true;
|
||||
cvar.notify_all();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark download as failed
|
||||
///
|
||||
/// After this is called, readers will receive the error on next read.
|
||||
pub fn error(&self, err: String) -> Result<(), String> {
|
||||
let (lock, cvar) = &*self.state;
|
||||
let mut state = lock.lock().map_err(|_| "Failed to acquire buffer lock")?;
|
||||
|
||||
state.download_error = Some(err);
|
||||
cvar.notify_all();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current buffer size in bytes
|
||||
pub fn buffer_size(&self) -> usize {
|
||||
let (lock, _) = &*self.state;
|
||||
if let Ok(state) = lock.lock() {
|
||||
state.data.len()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IncrementalStreamingSource - A rodio Source that decodes on-demand
|
||||
// =============================================================================
|
||||
|
||||
/// A rodio Source that decodes audio packets incrementally from a BufferedMediaSource.
|
||||
///
|
||||
/// This allows playback to start immediately after the initial buffer is filled,
|
||||
/// while the rest of the file continues downloading in the background.
|
||||
///
|
||||
/// The source maintains an internal queue of decoded samples and decodes more
|
||||
/// packets on-demand as samples are consumed.
|
||||
pub struct IncrementalStreamingSource {
|
||||
/// Sample rate of the audio
|
||||
sample_rate: u32,
|
||||
/// Number of channels
|
||||
channels: u16,
|
||||
/// Queue of decoded samples ready to play
|
||||
sample_queue: VecDeque<i16>,
|
||||
/// The format reader (demuxer)
|
||||
format: Box<dyn FormatReader>,
|
||||
/// The audio decoder
|
||||
decoder: Box<dyn Decoder>,
|
||||
/// Track ID we're decoding
|
||||
track_id: u32,
|
||||
/// Whether we've reached end of stream
|
||||
finished: bool,
|
||||
/// Number of packets decoded (for stats)
|
||||
packets_decoded: u64,
|
||||
/// Reference to the buffered source (for cache retrieval after playback)
|
||||
buffered_source: Arc<BufferedMediaSource>,
|
||||
}
|
||||
|
||||
impl IncrementalStreamingSource {
|
||||
/// Create a new incremental streaming source.
|
||||
///
|
||||
/// This initializes the symphonia decoder and prepares for incremental decoding.
|
||||
/// The BufferedMediaSource should already have its initial buffer filled.
|
||||
///
|
||||
/// Returns the source along with detected sample_rate and channels.
|
||||
pub fn new(
|
||||
buffered_source: Arc<BufferedMediaSource>,
|
||||
) -> Result<Self, String> {
|
||||
// Create a reader from the buffered source
|
||||
let reader = buffered_source.create_reader();
|
||||
let media_source = Box::new(reader) as Box<dyn MediaSource>;
|
||||
let mss = MediaSourceStream::new(media_source, Default::default());
|
||||
|
||||
let mut hint = Hint::new();
|
||||
hint.with_extension("flac"); // Most Qobuz Hi-Res is FLAC
|
||||
|
||||
let format_opts = FormatOptions {
|
||||
enable_gapless: true,
|
||||
..Default::default()
|
||||
};
|
||||
let metadata_opts: MetadataOptions = Default::default();
|
||||
|
||||
let probed = get_probe()
|
||||
.format(&hint, mss, &format_opts, &metadata_opts)
|
||||
.map_err(|err| format!("Symphonia probe failed for streaming: {}", err))?;
|
||||
|
||||
let track = probed
|
||||
.format
|
||||
.default_track()
|
||||
.ok_or_else(|| "Symphonia: no supported audio tracks in stream".to_string())?;
|
||||
|
||||
let track_id = track.id;
|
||||
let codec_params = track.codec_params.clone();
|
||||
|
||||
// Extract sample rate and channels from codec params
|
||||
let sample_rate = codec_params
|
||||
.sample_rate
|
||||
.ok_or_else(|| "No sample rate in codec params".to_string())?;
|
||||
let channels = codec_params
|
||||
.channels
|
||||
.map(|c| c.count() as u16)
|
||||
.unwrap_or(2);
|
||||
|
||||
let decoder = get_codecs()
|
||||
.make(&codec_params, &DecoderOptions::default())
|
||||
.map_err(|err| format!("Symphonia decoder init failed for streaming: {}", err))?;
|
||||
|
||||
log::info!(
|
||||
"IncrementalStreamingSource initialized: {}Hz, {} channels",
|
||||
sample_rate,
|
||||
channels
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
sample_rate,
|
||||
channels,
|
||||
sample_queue: VecDeque::with_capacity(sample_rate as usize * channels as usize), // ~1s buffer
|
||||
format: probed.format,
|
||||
decoder,
|
||||
track_id,
|
||||
finished: false,
|
||||
packets_decoded: 0,
|
||||
buffered_source,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the sample rate
|
||||
pub fn get_sample_rate(&self) -> u32 {
|
||||
self.sample_rate
|
||||
}
|
||||
|
||||
/// Get the number of channels
|
||||
pub fn get_channels(&self) -> u16 {
|
||||
self.channels
|
||||
}
|
||||
|
||||
/// Get reference to the buffered source for cache retrieval
|
||||
pub fn buffered_source(&self) -> &Arc<BufferedMediaSource> {
|
||||
&self.buffered_source
|
||||
}
|
||||
|
||||
/// Decode more packets to fill the sample queue.
|
||||
///
|
||||
/// This is called when the sample queue is running low.
|
||||
/// It will decode packets until the queue has at least `min_samples` or EOF is reached.
|
||||
fn decode_more(&mut self, min_samples: usize) {
|
||||
if self.finished {
|
||||
return;
|
||||
}
|
||||
|
||||
while self.sample_queue.len() < min_samples {
|
||||
let packet = match self.format.next_packet() {
|
||||
Ok(packet) => packet,
|
||||
Err(SymphoniaError::IoError(ref e)) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
// Not enough data buffered yet - wait briefly and retry
|
||||
// This happens when playback catches up with download
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
continue;
|
||||
}
|
||||
Err(SymphoniaError::IoError(_)) => {
|
||||
// EOF or other IO error
|
||||
log::info!(
|
||||
"IncrementalStreamingSource: EOF reached after {} packets",
|
||||
self.packets_decoded
|
||||
);
|
||||
self.finished = true;
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Symphonia read error in stream: {}", err);
|
||||
self.finished = true;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if packet.track_id() != self.track_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
match self.decoder.decode(&packet) {
|
||||
Ok(audio_buf) => {
|
||||
let spec = *audio_buf.spec();
|
||||
let mut sample_buf = SampleBuffer::<i16>::new(audio_buf.frames() as u64, spec);
|
||||
sample_buf.copy_interleaved_ref(audio_buf);
|
||||
|
||||
// Add samples to queue
|
||||
self.sample_queue.extend(sample_buf.samples().iter().copied());
|
||||
self.packets_decoded += 1;
|
||||
}
|
||||
Err(SymphoniaError::DecodeError(e)) => {
|
||||
log::warn!("Decode error (skipping packet): {}", e);
|
||||
continue;
|
||||
}
|
||||
Err(SymphoniaError::ResetRequired) => {
|
||||
self.decoder.reset();
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Symphonia decode error: {}", err);
|
||||
self.finished = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Source for IncrementalStreamingSource {
|
||||
fn current_frame_len(&self) -> Option<usize> {
|
||||
// We don't know frame boundaries in the queue
|
||||
None
|
||||
}
|
||||
|
||||
fn channels(&self) -> u16 {
|
||||
self.channels
|
||||
}
|
||||
|
||||
fn sample_rate(&self) -> u32 {
|
||||
self.sample_rate
|
||||
}
|
||||
|
||||
fn total_duration(&self) -> Option<Duration> {
|
||||
// We don't know total duration until download completes
|
||||
// Could estimate from content-length if available
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for IncrementalStreamingSource {
|
||||
type Item = i16;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
// If queue is running low, decode more
|
||||
// Keep at least 0.5 seconds of audio buffered
|
||||
let min_buffer = (self.sample_rate as usize * self.channels as usize) / 2;
|
||||
if self.sample_queue.len() < min_buffer {
|
||||
self.decode_more(min_buffer);
|
||||
}
|
||||
|
||||
self.sample_queue.pop_front()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_basic_read_write() {
|
||||
let config = StreamingConfig {
|
||||
initial_buffer_bytes: 10,
|
||||
max_buffer_bytes: 100,
|
||||
};
|
||||
let (mut source, writer) = BufferedMediaSource::new(config, Some(20));
|
||||
|
||||
// Write some data
|
||||
writer.push_chunk(b"Hello").unwrap();
|
||||
writer.push_chunk(b"World").unwrap();
|
||||
|
||||
// Read it back
|
||||
let mut buf = [0u8; 5];
|
||||
assert_eq!(source.read(&mut buf).unwrap(), 5);
|
||||
assert_eq!(&buf, b"Hello");
|
||||
|
||||
assert_eq!(source.read(&mut buf).unwrap(), 5);
|
||||
assert_eq!(&buf, b"World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seek_within_buffer() {
|
||||
let config = StreamingConfig {
|
||||
initial_buffer_bytes: 5,
|
||||
max_buffer_bytes: 100,
|
||||
};
|
||||
let (mut source, writer) = BufferedMediaSource::new(config, Some(10));
|
||||
|
||||
writer.push_chunk(b"0123456789").unwrap();
|
||||
writer.complete().unwrap();
|
||||
|
||||
// Read first 5 bytes
|
||||
let mut buf = [0u8; 5];
|
||||
source.read(&mut buf).unwrap();
|
||||
assert_eq!(&buf, b"01234");
|
||||
|
||||
// Seek back to start
|
||||
source.seek(SeekFrom::Start(0)).unwrap();
|
||||
source.read(&mut buf).unwrap();
|
||||
assert_eq!(&buf, b"01234");
|
||||
|
||||
// Seek to middle
|
||||
source.seek(SeekFrom::Start(3)).unwrap();
|
||||
source.read(&mut buf).unwrap();
|
||||
assert_eq!(&buf, b"34567");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complete_data_retrieval() {
|
||||
let config = StreamingConfig {
|
||||
initial_buffer_bytes: 5,
|
||||
max_buffer_bytes: 100,
|
||||
};
|
||||
let (source, writer) = BufferedMediaSource::new(config, Some(10));
|
||||
|
||||
writer.push_chunk(b"Hello").unwrap();
|
||||
assert!(source.take_complete_data().is_none()); // Not complete yet
|
||||
|
||||
writer.push_chunk(b"World").unwrap();
|
||||
writer.complete().unwrap();
|
||||
|
||||
let data = source.take_complete_data().unwrap();
|
||||
assert_eq!(&data, b"HelloWorld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blocking_read() {
|
||||
let config = StreamingConfig {
|
||||
initial_buffer_bytes: 5,
|
||||
max_buffer_bytes: 100,
|
||||
};
|
||||
let (mut source, writer) = BufferedMediaSource::new(config, None);
|
||||
|
||||
// Spawn thread to write after delay
|
||||
thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
writer.push_chunk(b"Delayed").unwrap();
|
||||
writer.complete().unwrap();
|
||||
});
|
||||
|
||||
// This should block until data arrives
|
||||
let mut buf = [0u8; 7];
|
||||
let n = source.read(&mut buf).unwrap();
|
||||
assert_eq!(n, 7);
|
||||
assert_eq!(&buf, b"Delayed");
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ pub async fn match_tracks(
|
||||
for track in tracks {
|
||||
let query = format!("{} {}", track.artist, track.title);
|
||||
let search = client
|
||||
.search_tracks(&query, SEARCH_LIMIT, 0)
|
||||
.search_tracks(&query, SEARCH_LIMIT, 0, None)
|
||||
.await
|
||||
.map_err(|e| PlaylistImportError::Qobuz(e.to_string()))?;
|
||||
|
||||
|
||||
@@ -260,7 +260,8 @@ function toArtistAlbumSummary(album: QobuzAlbum): ArtistAlbumSummary {
|
||||
title: album.title,
|
||||
artwork,
|
||||
year: album.release_date_original?.split('-')[0],
|
||||
quality
|
||||
quality,
|
||||
genre: album.genre?.name || "Unknown genre"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -284,7 +285,8 @@ function buildCompilationAlbums(tracks: QobuzTrack[] | undefined): ArtistAlbumSu
|
||||
track.hires_streamable,
|
||||
track.maximum_bit_depth,
|
||||
track.maximum_sampling_rate
|
||||
)
|
||||
),
|
||||
genre: album.genre?.name || ''
|
||||
});
|
||||
}
|
||||
|
||||
@@ -325,6 +327,7 @@ export function convertQobuzAlbum(album: QobuzAlbum): AlbumDetail {
|
||||
year: album.release_date_original?.split('-')[0] || '',
|
||||
releaseDate: album.release_date_original,
|
||||
label: album.label?.name || '',
|
||||
labelId: album.label?.id,
|
||||
genre: album.genre?.name || '',
|
||||
quality,
|
||||
trackCount: album.tracks_count || album.tracks?.items?.length || 0,
|
||||
@@ -389,6 +392,33 @@ function categorizeAlbum(album: QobuzAlbum, mainArtistId: number): 'tributes' |
|
||||
return 'albums';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract unique labels from albums
|
||||
* Only considers albums categorized as 'albums' (main Discography)
|
||||
* to avoid pollution from tributes, compilations, etc.
|
||||
*/
|
||||
function extractLabelsFromAlbums(albums: QobuzAlbum[], artistId: number): { id: number; name: string }[] {
|
||||
const labelMap = new Map<number, string>();
|
||||
|
||||
for (const album of albums) {
|
||||
// Only extract labels from main discography albums
|
||||
const category = categorizeAlbum(album, artistId);
|
||||
if (category !== 'albums') continue;
|
||||
|
||||
if (album.label?.id && album.label?.name) {
|
||||
// Only add if we haven't seen this label ID before
|
||||
if (!labelMap.has(album.label.id)) {
|
||||
labelMap.set(album.label.id, album.label.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array and sort by name
|
||||
return Array.from(labelMap.entries())
|
||||
.map(([id, name]) => ({ id, name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Qobuz API artist response to UI ArtistDetail model
|
||||
*/
|
||||
@@ -440,6 +470,7 @@ export function convertQobuzArtist(artist: QobuzArtist): ArtistDetail {
|
||||
tributes,
|
||||
others,
|
||||
playlists: toArtistPlaylists(artist.playlists),
|
||||
labels: extractLabelsFromAlbums(albumItems, artist.id),
|
||||
totalAlbums: artist.albums?.total || artist.albums_count || 0,
|
||||
albumsFetched
|
||||
};
|
||||
@@ -504,6 +535,13 @@ export function appendArtistAlbums(
|
||||
}
|
||||
}
|
||||
|
||||
// Merge new labels from new albums (only from main discography)
|
||||
const existingLabelIds = new Set(artist.labels.map(l => l.id));
|
||||
const newLabels = extractLabelsFromAlbums(newAlbums, artist.id)
|
||||
.filter(l => !existingLabelIds.has(l.id));
|
||||
const labels = [...artist.labels, ...newLabels]
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return {
|
||||
...artist,
|
||||
albums,
|
||||
@@ -511,6 +549,7 @@ export function appendArtistAlbums(
|
||||
liveAlbums,
|
||||
tributes,
|
||||
others,
|
||||
labels,
|
||||
totalAlbums: totalAlbums ?? artist.totalAlbums,
|
||||
albumsFetched: albumsFetched ?? artist.albumsFetched + newAlbums.length
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
artwork: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
genre: string;
|
||||
quality?: string;
|
||||
size?: 'standard' | 'large';
|
||||
searchId?: string;
|
||||
@@ -38,6 +39,7 @@
|
||||
artwork,
|
||||
title,
|
||||
artist,
|
||||
genre,
|
||||
quality,
|
||||
size = 'standard',
|
||||
searchId,
|
||||
@@ -182,6 +184,7 @@
|
||||
<!-- Action Overlay -->
|
||||
{#if hasOverlay}
|
||||
<div class="action-overlay" class:menu-open={menuOpen}>
|
||||
<div class="overlay-genre">{genre}</div>
|
||||
<div class="action-buttons">
|
||||
{#if showFavoriteButton}
|
||||
<button
|
||||
@@ -322,7 +325,7 @@
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
background: rgba(10, 10, 10, 0.25);
|
||||
background: rgba(10, 10, 10, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
pointer-events: auto;
|
||||
@@ -390,6 +393,23 @@
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.overlay-genre {
|
||||
align-self: flex-start;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding-left: 1em;
|
||||
color: white;
|
||||
text-shadow:
|
||||
0.06em 0 black,
|
||||
0 0.06em black,
|
||||
-0.06em 0 black,
|
||||
0 -0.06em black,
|
||||
-0.06em -0.06em black,
|
||||
-0.06em 0.06em black,
|
||||
0.06em -0.06em black,
|
||||
0.06em 0.06em black;
|
||||
}
|
||||
|
||||
:global(.album-card .album-menu) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -9,12 +9,18 @@
|
||||
onClose: () => void;
|
||||
onTrackPlay?: (track: TrackCredits) => void;
|
||||
onPerformerSearch?: (name: string) => void;
|
||||
onMusicianClick?: (name: string, role: string) => void;
|
||||
onLabelClick?: (labelId: number, labelName: string) => void;
|
||||
}
|
||||
|
||||
let { isOpen, albumId, onClose, onTrackPlay, onPerformerSearch }: Props = $props();
|
||||
let { isOpen, albumId, onClose, onTrackPlay, onPerformerSearch, onMusicianClick, onLabelClick }: Props = $props();
|
||||
|
||||
function handlePerformerClick(name: string) {
|
||||
if (onPerformerSearch) {
|
||||
function handlePerformerClick(name: string, roles: string[]) {
|
||||
const role = roles.length > 0 ? roles[0] : 'Performer';
|
||||
if (onMusicianClick) {
|
||||
onMusicianClick(name, role);
|
||||
onClose();
|
||||
} else if (onPerformerSearch) {
|
||||
onPerformerSearch(name);
|
||||
onClose();
|
||||
}
|
||||
@@ -121,7 +127,19 @@
|
||||
{#if credits.album.label}
|
||||
<p class="meta-row">
|
||||
<span class="meta-label">Released by</span>
|
||||
<span class="meta-value label-name">{credits.album.label}</span>
|
||||
{#if credits.album.label_id && onLabelClick}
|
||||
<button
|
||||
class="label-link"
|
||||
onclick={() => {
|
||||
onLabelClick!(credits!.album.label_id!, credits!.album.label);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{credits.album.label}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="meta-value label-name">{credits.album.label}</span>
|
||||
{/if}
|
||||
{#if credits.album.release_date}
|
||||
<span class="meta-date">on {new Date(credits.album.release_date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}</span>
|
||||
{/if}
|
||||
@@ -228,8 +246,8 @@
|
||||
<div class="track-credits">
|
||||
{#each track.performers as performer}
|
||||
<div class="performer-row">
|
||||
{#if onPerformerSearch}
|
||||
<button class="performer-link" onclick={() => handlePerformerClick(performer.name)}>{performer.name}</button>
|
||||
{#if onMusicianClick || onPerformerSearch}
|
||||
<button class="performer-link" onclick={() => handlePerformerClick(performer.name, performer.roles)}>{performer.name}</button>
|
||||
{:else}
|
||||
<span class="performer-name">{performer.name}</span>
|
||||
{/if}
|
||||
@@ -448,6 +466,22 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.label-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.label-link:hover {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.meta-date {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Musician Informational Modal
|
||||
*
|
||||
* Displays contextual information about a musician when navigation
|
||||
* to a full Musician Page is not appropriate (confidence level 0-1).
|
||||
*
|
||||
* This modal:
|
||||
* - Is NOT a page, provides context only
|
||||
* - Returns user to previous view on close
|
||||
* - Does NOT show albums, tracks, or playlists
|
||||
* - Does NOT allow deep navigation
|
||||
*/
|
||||
import { X, User, Music, Info } from 'lucide-svelte';
|
||||
import type { ResolvedMusician } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
musician: ResolvedMusician;
|
||||
onClose: () => void;
|
||||
onNavigateToArtist?: (artistId: number) => void;
|
||||
}
|
||||
|
||||
let { musician, onClose, onNavigateToArtist }: Props = $props();
|
||||
|
||||
// Explanatory copy based on confidence level
|
||||
const explanatoryCopy = $derived(() => {
|
||||
switch (musician.confidence) {
|
||||
case 'weak':
|
||||
return 'This musician appears in album credits but has limited catalog information available in Qobuz.';
|
||||
case 'none':
|
||||
return 'Unable to verify this musician\'s catalog. The information shown is based on album credits.';
|
||||
default:
|
||||
return 'This musician appears in album credits.';
|
||||
}
|
||||
});
|
||||
|
||||
// Show CTA only if we have a Qobuz artist ID (confidence = confirmed)
|
||||
const showArtistCta = $derived(
|
||||
musician.confidence === 'confirmed' && musician.qobuz_artist_id
|
||||
);
|
||||
|
||||
function handleNavigateToArtist() {
|
||||
if (musician.qobuz_artist_id && onNavigateToArtist) {
|
||||
onNavigateToArtist(musician.qobuz_artist_id);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
onclick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="musician-modal-title"
|
||||
>
|
||||
<div class="modal">
|
||||
<!-- Header -->
|
||||
<div class="modal-header">
|
||||
<div class="musician-info">
|
||||
<div class="musician-icon">
|
||||
<User size={24} />
|
||||
</div>
|
||||
<div class="musician-details">
|
||||
<h2 id="musician-modal-title">{musician.name}</h2>
|
||||
<span class="role">{musician.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="close-btn" onclick={onClose} title="Close">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="modal-content">
|
||||
<!-- Bands & Projects -->
|
||||
{#if musician.bands.length > 0}
|
||||
<div class="section">
|
||||
<h3>Known for</h3>
|
||||
<div class="bands-list">
|
||||
{#each musician.bands as band}
|
||||
<div class="band-item">
|
||||
<Music size={14} />
|
||||
<span>{band}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Explanatory Copy -->
|
||||
<div class="section info-section">
|
||||
<div class="info-box">
|
||||
<Info size={16} />
|
||||
<p>{explanatoryCopy()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA Button (only for confirmed matches) -->
|
||||
{#if showArtistCta}
|
||||
<div class="section cta-section">
|
||||
<button class="cta-btn" onclick={handleNavigateToArtist}>
|
||||
View Artist Page
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
animation: fadeIn 150ms ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 420px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.4);
|
||||
animation: slideUp 200ms ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 24px 24px 16px;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.musician-info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.musician-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.musician-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.musician-details h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.role {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.bands-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.band-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.band-item :global(svg) {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.info-box :global(svg) {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cta-section {
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.cta-btn {
|
||||
width: 100%;
|
||||
padding: 12px 20px;
|
||||
background: var(--accent-primary);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.cta-btn:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
</style>
|
||||
@@ -9,9 +9,21 @@
|
||||
onClose: () => void;
|
||||
onArtistClick?: (artistId: number) => void;
|
||||
onPerformerSearch?: (name: string) => void;
|
||||
onMusicianClick?: (name: string, role: string) => void;
|
||||
onLabelClick?: (labelId: number, labelName: string) => void;
|
||||
}
|
||||
|
||||
let { isOpen, trackId, onClose, onArtistClick, onPerformerSearch }: Props = $props();
|
||||
let { isOpen, trackId, onClose, onArtistClick, onPerformerSearch, onMusicianClick, onLabelClick }: Props = $props();
|
||||
|
||||
function handlePerformerClick(name: string, role: string) {
|
||||
if (onMusicianClick) {
|
||||
onMusicianClick(name, role);
|
||||
onClose();
|
||||
} else if (onPerformerSearch) {
|
||||
onPerformerSearch(name);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
@@ -187,6 +199,24 @@
|
||||
<span class="metadata-value mono">{trackInfo.track.isrc}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if trackInfo.track.album?.label}
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">LABEL</span>
|
||||
{#if onLabelClick}
|
||||
<button
|
||||
class="label-link"
|
||||
onclick={() => {
|
||||
onLabelClick!(trackInfo!.track.album!.label!.id, trackInfo!.track.album!.label!.name);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{trackInfo.track.album.label.name}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="metadata-value">{trackInfo.track.album.label.name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Credits Grid (2 columns) -->
|
||||
@@ -198,10 +228,10 @@
|
||||
<span class="credit-label">{formatRole(role)}</span>
|
||||
<span class="credit-value">
|
||||
{#each names as name, i}
|
||||
{#if onPerformerSearch}
|
||||
{#if onMusicianClick || onPerformerSearch}
|
||||
<button
|
||||
class="performer-link"
|
||||
onclick={() => { onPerformerSearch(name); onClose(); }}
|
||||
onclick={() => handlePerformerClick(name, role)}
|
||||
>{name}</button>{#if i < names.length - 1}, {/if}
|
||||
{:else}
|
||||
{name}{#if i < names.length - 1}, {/if}
|
||||
@@ -465,4 +495,21 @@
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Label link */
|
||||
.label-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
color: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.label-link:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -312,6 +312,7 @@
|
||||
artwork={getArtworkUrl(album.artwork_path)}
|
||||
title={album.title}
|
||||
artist={album.artist}
|
||||
genre={"Unknown genre"}
|
||||
quality={getQualityBadge(album)}
|
||||
size="large"
|
||||
showFavorite={true}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
title: string;
|
||||
artwork: string;
|
||||
quality: string;
|
||||
genre: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -44,6 +45,7 @@
|
||||
year: string;
|
||||
releaseDate?: string;
|
||||
label: string;
|
||||
labelId?: number;
|
||||
genre: string;
|
||||
quality: string;
|
||||
trackCount: number;
|
||||
@@ -52,6 +54,7 @@
|
||||
};
|
||||
onBack: () => void;
|
||||
onArtistClick?: () => void;
|
||||
onLabelClick?: (labelId: number, labelName: string) => void;
|
||||
onTrackPlay?: (track: Track) => void;
|
||||
onTrackPlayNext?: (track: Track) => void;
|
||||
onTrackPlayLater?: (track: Track) => void;
|
||||
@@ -97,6 +100,7 @@
|
||||
album,
|
||||
onBack,
|
||||
onArtistClick,
|
||||
onLabelClick,
|
||||
onTrackPlay,
|
||||
onTrackPlayNext,
|
||||
onTrackPlayLater,
|
||||
@@ -318,7 +322,17 @@
|
||||
{:else}
|
||||
<div class="artist-name">{album.artist}</div>
|
||||
{/if}
|
||||
<div class="album-info">{formattedReleaseDate} • {album.label} • {album.genre}</div>
|
||||
<div class="album-info">
|
||||
{formattedReleaseDate} •
|
||||
{#if album.labelId && onLabelClick}
|
||||
<button class="label-link" onclick={() => onLabelClick!(album.labelId!, album.label)}>
|
||||
{album.label}
|
||||
</button>
|
||||
{:else}
|
||||
{album.label}
|
||||
{/if}
|
||||
• {album.genre}
|
||||
</div>
|
||||
<div class="album-quality">{album.quality}</div>
|
||||
<div class="album-stats">{album.trackCount} tracks • {album.duration}</div>
|
||||
|
||||
@@ -470,6 +484,7 @@
|
||||
artwork={relatedAlbum.artwork}
|
||||
title={relatedAlbum.title}
|
||||
artist={album.artist}
|
||||
genre={relatedAlbum.genre}
|
||||
size="large"
|
||||
quality={relatedAlbum.quality}
|
||||
onclick={() => onRelatedAlbumClick?.(relatedAlbum.id)}
|
||||
@@ -608,6 +623,21 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.label-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.label-link:hover {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.album-info {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ArrowLeft, User, ChevronDown, ChevronUp, Play, Music, Heart, Search, X, ChevronLeft, ChevronRight, Radio, MoreHorizontal, Info } from 'lucide-svelte';
|
||||
import { ArrowLeft, User, ChevronDown, ChevronUp, Play, Music, Heart, Search, X, ChevronLeft, ChevronRight, Radio, MoreHorizontal, Info, Disc } from 'lucide-svelte';
|
||||
import type { ArtistDetail, QobuzArtist } from '$lib/types';
|
||||
import AlbumCard from '../AlbumCard.svelte';
|
||||
import TrackMenu from '../TrackMenu.svelte';
|
||||
import { consumeContextTrackFocus, setPlaybackContext, getPlaybackContext } from '$lib/stores/playbackContextStore';
|
||||
import { togglePlay } from '$lib/stores/playerStore';
|
||||
import { getQueue, syncQueueState, playQueueIndex } from '$lib/stores/queueStore';
|
||||
import { tick } from 'svelte';
|
||||
import { subscribeContentSidebar, toggleContentSidebar, type ContentSidebarType } from '$lib/stores/sidebarStore';
|
||||
import { tick, onMount, onDestroy } from 'svelte';
|
||||
|
||||
interface Track {
|
||||
id: number;
|
||||
@@ -73,6 +74,8 @@
|
||||
onTrackShareSonglink?: (track: Track) => void;
|
||||
onTrackGoToAlbum?: (albumId: string) => void;
|
||||
onTrackGoToArtist?: (artistId: number) => void;
|
||||
onLabelClick?: (labelId: number, labelName?: string) => void;
|
||||
onMusicianClick?: (name: string, role: string) => void;
|
||||
activeTrackId?: number | null;
|
||||
isPlaybackActive?: boolean;
|
||||
}
|
||||
@@ -104,6 +107,8 @@
|
||||
onTrackShareSonglink,
|
||||
onTrackGoToAlbum,
|
||||
onTrackGoToArtist,
|
||||
onLabelClick,
|
||||
onMusicianClick,
|
||||
activeTrackId = null,
|
||||
isPlaybackActive = false
|
||||
}: Props = $props();
|
||||
@@ -117,9 +122,47 @@
|
||||
let isRadioLoading = $state(false);
|
||||
let radioLoadingMessage = $state('');
|
||||
let radioJustCreated = $state(false);
|
||||
let showNetworkSidebar = $state(false);
|
||||
let unsubscribeSidebar: (() => void) | null = null;
|
||||
|
||||
onMount(() => {
|
||||
unsubscribeSidebar = subscribeContentSidebar((active: ContentSidebarType) => {
|
||||
showNetworkSidebar = active === 'network';
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribeSidebar?.();
|
||||
});
|
||||
let similarArtists = $state<QobuzArtist[]>([]);
|
||||
let similarArtistsLoading = $state(false);
|
||||
let similarArtistImageErrors = $state<Set<number>>(new Set());
|
||||
|
||||
// MusicBrainz relationships (Stage 3)
|
||||
interface RelatedArtist {
|
||||
mbid: string;
|
||||
name: string;
|
||||
role?: string;
|
||||
period?: { begin?: string; end?: string };
|
||||
ended: boolean;
|
||||
}
|
||||
interface ArtistRelationships {
|
||||
members: RelatedArtist[];
|
||||
pastMembers: RelatedArtist[];
|
||||
groups: RelatedArtist[];
|
||||
collaborators: RelatedArtist[];
|
||||
}
|
||||
interface GroupedMember {
|
||||
mbid: string;
|
||||
name: string;
|
||||
roles: string[];
|
||||
period?: { begin?: string; end?: string };
|
||||
ended: boolean;
|
||||
}
|
||||
let mbRelationships = $state<ArtistRelationships | null>(null);
|
||||
let mbRelationshipsLoading = $state(false);
|
||||
let mbArtistMbid = $state<string | null>(null);
|
||||
let mbAvailable = $state(true); // Assume available until proven otherwise
|
||||
let artistDetailEl = $state<HTMLDivElement | null>(null);
|
||||
let aboutSection = $state<HTMLDivElement | null>(null);
|
||||
let topTracksSection = $state<HTMLDivElement | null>(null);
|
||||
@@ -242,6 +285,7 @@
|
||||
|
||||
loadTopTracks();
|
||||
loadSimilarArtists();
|
||||
loadMusicBrainzRelationships();
|
||||
checkFavoriteStatus();
|
||||
loadArtistAlbumDownloadStatuses();
|
||||
});
|
||||
@@ -456,6 +500,130 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Load MusicBrainz relationships for artist enrichment
|
||||
async function loadMusicBrainzRelationships() {
|
||||
// First, resolve the artist to get MBID
|
||||
mbRelationshipsLoading = true;
|
||||
mbRelationships = null;
|
||||
mbArtistMbid = null;
|
||||
mbAvailable = true; // Reset on each load attempt
|
||||
|
||||
try {
|
||||
// Check if MusicBrainz is enabled
|
||||
const enabled = await invoke<boolean>('musicbrainz_is_enabled');
|
||||
if (!enabled) {
|
||||
mbAvailable = false;
|
||||
mbRelationshipsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve artist name to MBID
|
||||
const resolved = await invoke<{
|
||||
mbid?: string;
|
||||
name?: string;
|
||||
confidence: string;
|
||||
}>('musicbrainz_resolve_artist', { name: artist.name });
|
||||
|
||||
if (!resolved?.mbid || resolved.confidence === 'none') {
|
||||
mbRelationshipsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
mbArtistMbid = resolved.mbid;
|
||||
|
||||
// Fetch relationships
|
||||
const relationships = await invoke<{
|
||||
members: RelatedArtist[];
|
||||
past_members: RelatedArtist[];
|
||||
groups: RelatedArtist[];
|
||||
collaborators: RelatedArtist[];
|
||||
}>('musicbrainz_get_artist_relationships', { mbid: resolved.mbid });
|
||||
|
||||
mbRelationships = {
|
||||
members: relationships.members || [],
|
||||
pastMembers: relationships.past_members || [],
|
||||
groups: relationships.groups || [],
|
||||
collaborators: relationships.collaborators || []
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to load MusicBrainz relationships:', err);
|
||||
mbAvailable = false;
|
||||
mbRelationships = null;
|
||||
} finally {
|
||||
mbRelationshipsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to a related artist by searching Qobuz
|
||||
async function navigateToRelatedArtist(name: string) {
|
||||
try {
|
||||
// Search for the artist on Qobuz
|
||||
const results = await invoke<{ artists?: { items: QobuzArtist[] } }>('search_artists', {
|
||||
query: name,
|
||||
limit: 5
|
||||
});
|
||||
|
||||
if (results?.artists?.items?.length) {
|
||||
// Find the best match (exact name match or first result)
|
||||
const exactMatch = results.artists.items.find(
|
||||
a => a.name.toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
const artistToNavigate = exactMatch || results.artists.items[0];
|
||||
|
||||
// Use the existing navigation callback
|
||||
if (onTrackGoToArtist && artistToNavigate.id) {
|
||||
onTrackGoToArtist(artistToNavigate.id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to navigate to related artist:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have any relationships to show
|
||||
let hasRelationships = $derived(
|
||||
mbRelationships &&
|
||||
(mbRelationships.members.length > 0 ||
|
||||
mbRelationships.groups.length > 0 ||
|
||||
mbRelationships.collaborators.length > 0)
|
||||
);
|
||||
|
||||
// Group members by MBID, combining their roles
|
||||
function groupMembersByMbid(members: RelatedArtist[]): GroupedMember[] {
|
||||
const grouped = new Map<string, GroupedMember>();
|
||||
for (const member of members) {
|
||||
const existing = grouped.get(member.mbid);
|
||||
if (existing) {
|
||||
if (member.role && !existing.roles.includes(member.role)) {
|
||||
existing.roles.push(member.role);
|
||||
}
|
||||
// If any entry is ended, mark as ended
|
||||
if (member.ended) {
|
||||
existing.ended = true;
|
||||
}
|
||||
} else {
|
||||
grouped.set(member.mbid, {
|
||||
mbid: member.mbid,
|
||||
name: member.name,
|
||||
roles: member.role ? [member.role] : [],
|
||||
period: member.period,
|
||||
ended: member.ended
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(grouped.values());
|
||||
}
|
||||
|
||||
let groupedMembers = $derived(
|
||||
mbRelationships ? groupMembersByMbid(mbRelationships.members) : []
|
||||
);
|
||||
let groupedGroups = $derived(
|
||||
mbRelationships ? groupMembersByMbid(mbRelationships.groups) : []
|
||||
);
|
||||
let groupedCollaborators = $derived(
|
||||
mbRelationships ? groupMembersByMbid(mbRelationships.collaborators) : []
|
||||
);
|
||||
|
||||
function getSimilarArtistImage(similar: QobuzArtist): string {
|
||||
return (
|
||||
similar.image?.small ||
|
||||
@@ -929,6 +1097,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- TEMPORARILY HIDDEN FOR SIDEBAR EXPERIMENTS
|
||||
{#if similarArtistsLoading}
|
||||
<div class="similar-loading">Loading similar artists...</div>
|
||||
{:else if similarArtists.length > 0}
|
||||
@@ -951,6 +1120,74 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
MusicBrainz Relationships
|
||||
{#if mbRelationshipsLoading}
|
||||
<div class="mb-relationships-loading">Loading artist relationships...</div>
|
||||
{:else if hasRelationships}
|
||||
<div class="mb-relationships">
|
||||
{#if mbRelationships && mbRelationships.members.length > 0}
|
||||
<div class="mb-section">
|
||||
<div class="mb-section-title">BAND MEMBERS</div>
|
||||
<div class="mb-members-list">
|
||||
{#each mbRelationships.members as member}
|
||||
<button
|
||||
class="mb-member"
|
||||
onclick={() => navigateToRelatedArtist(member.name)}
|
||||
title={member.role ? `${member.name} (${member.role})` : member.name}
|
||||
>
|
||||
<User size={14} class="mb-member-icon" />
|
||||
<span class="mb-member-name">{member.name}</span>
|
||||
{#if member.role}
|
||||
<span class="mb-member-role">{member.role}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mbRelationships && mbRelationships.pastMembers.length > 0}
|
||||
<div class="mb-section">
|
||||
<div class="mb-section-title">PAST MEMBERS</div>
|
||||
<div class="mb-members-list">
|
||||
{#each mbRelationships.pastMembers as member}
|
||||
<button
|
||||
class="mb-member past"
|
||||
onclick={() => navigateToRelatedArtist(member.name)}
|
||||
title={member.role ? `${member.name} (${member.role})` : member.name}
|
||||
>
|
||||
<User size={14} class="mb-member-icon" />
|
||||
<span class="mb-member-name">{member.name}</span>
|
||||
{#if member.role}
|
||||
<span class="mb-member-role">{member.role}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mbRelationships && mbRelationships.groups.length > 0}
|
||||
<div class="mb-section">
|
||||
<div class="mb-section-title">MEMBER OF</div>
|
||||
<div class="mb-groups-list">
|
||||
{#each mbRelationships.groups as group}
|
||||
<button
|
||||
class="mb-group"
|
||||
onclick={() => navigateToRelatedArtist(group.name)}
|
||||
title={group.name}
|
||||
>
|
||||
<Music size={14} class="mb-group-icon" />
|
||||
<span class="mb-group-name">{group.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
END TEMPORARILY HIDDEN -->
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="artist-actions">
|
||||
<button
|
||||
@@ -983,6 +1220,14 @@
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="network-btn"
|
||||
class:active={showNetworkSidebar}
|
||||
onclick={() => toggleContentSidebar('network')}
|
||||
title="Artist Network"
|
||||
>
|
||||
<img src="/element-connect.svg" alt="Network" class="network-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1273,6 +1518,7 @@
|
||||
artwork={album.artwork}
|
||||
title={album.title}
|
||||
artist={album.year || ''}
|
||||
genre={album.genre}
|
||||
size="large"
|
||||
quality={album.quality}
|
||||
searchId={`album-${album.id}`}
|
||||
@@ -1334,6 +1580,7 @@
|
||||
artwork={album.artwork}
|
||||
title={album.title}
|
||||
artist={album.year || ''}
|
||||
genre={album.genre}
|
||||
size="large"
|
||||
quality={album.quality}
|
||||
searchId={`album-${album.id}`}
|
||||
@@ -1394,6 +1641,7 @@
|
||||
artwork={album.artwork}
|
||||
title={album.title}
|
||||
artist={album.year || ''}
|
||||
genre={album.genre}
|
||||
size="large"
|
||||
quality={album.quality}
|
||||
searchId={`album-${album.id}`}
|
||||
@@ -1454,6 +1702,7 @@
|
||||
artwork={album.artwork}
|
||||
title={album.title}
|
||||
artist={album.year || ''}
|
||||
genre={album.genre}
|
||||
size="large"
|
||||
quality={album.quality}
|
||||
searchId={`album-${album.id}`}
|
||||
@@ -1514,6 +1763,7 @@
|
||||
artwork={album.artwork}
|
||||
title={album.title}
|
||||
artist={album.year || ''}
|
||||
genre={album.genre}
|
||||
size="large"
|
||||
quality={album.quality}
|
||||
searchId={`album-${album.id}`}
|
||||
@@ -1637,6 +1887,7 @@
|
||||
artwork={album.artwork}
|
||||
title={album.title}
|
||||
artist={album.year || ''}
|
||||
genre={album.genre}
|
||||
size="large"
|
||||
quality={album.quality}
|
||||
searchId={`album-${album.id}`}
|
||||
@@ -1665,6 +1916,139 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Artist Network Sidebar -->
|
||||
{#if showNetworkSidebar}
|
||||
<aside class="network-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-header-icon">
|
||||
<img src="/element-connect.svg" alt="" />
|
||||
</div>
|
||||
<div class="sidebar-header-text">
|
||||
<h3 class="sidebar-title">Network</h3>
|
||||
<div class="sidebar-subtitle">{artist.name}</div>
|
||||
</div>
|
||||
<button class="sidebar-close" onclick={() => toggleContentSidebar('network')} title="Close">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<!-- Labels Section -->
|
||||
<section class="sidebar-section">
|
||||
<h4 class="section-label">LABELS</h4>
|
||||
<div class="section-items">
|
||||
{#if artist.labels.length > 0}
|
||||
{#each artist.labels as label}
|
||||
<button
|
||||
class="sidebar-artist-link"
|
||||
onclick={() => onLabelClick?.(label.id, label.name)}
|
||||
title={label.name}
|
||||
>
|
||||
<Disc size={12} />
|
||||
{label.name}
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="placeholder-text">No label info</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Similar Artists Section -->
|
||||
<section class="sidebar-section">
|
||||
<h4 class="section-label">SIMILAR ARTISTS</h4>
|
||||
<div class="section-items">
|
||||
{#if similarArtistsLoading}
|
||||
<span class="placeholder-text">Loading...</span>
|
||||
{:else if similarArtists.length > 0}
|
||||
{#each similarArtists as similar}
|
||||
<button
|
||||
class="sidebar-artist-link"
|
||||
onclick={() => onTrackGoToArtist?.(similar.id)}
|
||||
title={similar.name}
|
||||
>
|
||||
<User size={12} />
|
||||
{similar.name}
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="placeholder-text">No similar artists found</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- MusicBrainz Relationships (only shown if MB is enabled and available) -->
|
||||
{#if mbAvailable}
|
||||
<section class="sidebar-section">
|
||||
<h4 class="section-label">RELATIONSHIPS</h4>
|
||||
<div class="section-items">
|
||||
{#if mbRelationshipsLoading}
|
||||
<span class="placeholder-text">Loading...</span>
|
||||
{:else if hasRelationships}
|
||||
{#if groupedMembers.length > 0}
|
||||
<div class="relationship-group">
|
||||
<span class="relationship-label">Members & Former</span>
|
||||
{#each groupedMembers as member}
|
||||
{@const periodStr = member.period?.begin || member.period?.end
|
||||
? `${member.period.begin || '?'} - ${member.period.end || 'present'}`
|
||||
: ''}
|
||||
{@const tooltipParts = [...member.roles]}
|
||||
{@const tooltip = tooltipParts.length > 0
|
||||
? (periodStr ? `${tooltipParts.join(', ')} (${periodStr})` : tooltipParts.join(', '))
|
||||
: (periodStr || member.name)}
|
||||
{@const memberRole = member.roles[0] || 'Band Member'}
|
||||
<button
|
||||
class="sidebar-artist-link"
|
||||
onclick={() => onMusicianClick ? onMusicianClick(member.name, memberRole) : navigateToRelatedArtist(member.name)}
|
||||
title={tooltip}
|
||||
>
|
||||
<User size={12} />
|
||||
{member.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if groupedGroups.length > 0}
|
||||
<div class="relationship-group">
|
||||
<span class="relationship-label">Member Of</span>
|
||||
{#each groupedGroups as group}
|
||||
<button
|
||||
class="sidebar-artist-link"
|
||||
onclick={() => onMusicianClick ? onMusicianClick(group.name, 'Band') : navigateToRelatedArtist(group.name)}
|
||||
title={group.roles.length > 0 ? group.roles.join(', ') : group.name}
|
||||
>
|
||||
<Music size={12} />
|
||||
{group.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if groupedCollaborators.length > 0}
|
||||
<div class="relationship-group">
|
||||
<span class="relationship-label">Collaborators</span>
|
||||
{#each groupedCollaborators as collab}
|
||||
{@const collabRole = collab.roles[0] || 'Collaborator'}
|
||||
<button
|
||||
class="sidebar-artist-link"
|
||||
onclick={() => onMusicianClick ? onMusicianClick(collab.name, collabRole) : navigateToRelatedArtist(collab.name)}
|
||||
title={collab.roles.length > 0 ? collab.roles.join(', ') : collab.name}
|
||||
>
|
||||
<User size={12} />
|
||||
{collab.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="placeholder-text">No relationships found</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -1677,6 +2061,175 @@
|
||||
padding-right: 8px;
|
||||
padding-bottom: 100px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Network Sidebar - matches LyricsSidebar dimensions */
|
||||
.network-sidebar {
|
||||
position: fixed;
|
||||
top: 32px;
|
||||
right: 0;
|
||||
width: 340px;
|
||||
height: calc(100vh - 32px - 72px);
|
||||
background: var(--bg-secondary);
|
||||
border-left: 1px solid var(--bg-tertiary);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideIn 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
background: var(--bg-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-header-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.sidebar-header-icon img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
filter: brightness(0) saturate(100%) invert(56%) sepia(63%) saturate(4848%) hue-rotate(230deg) brightness(102%) contrast(101%);
|
||||
}
|
||||
|
||||
.sidebar-header-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sidebar-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
transition: background 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.sidebar-close:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.sidebar-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.section-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.sidebar-artist-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
transition: background 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.sidebar-artist-link:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
.relationship-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.relationship-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.relationship-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
@@ -1880,6 +2433,42 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.network-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: var(--bg-tertiary);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
transition: background 200ms ease, color 200ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.network-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.network-btn.active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.network-btn .network-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
filter: brightness(0) saturate(100%) invert(70%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(90%) contrast(90%);
|
||||
transition: filter 150ms ease;
|
||||
}
|
||||
|
||||
.network-btn:hover .network-icon,
|
||||
.network-btn.active .network-icon {
|
||||
filter: brightness(0) saturate(100%) invert(56%) sepia(63%) saturate(4848%) hue-rotate(230deg) brightness(102%) contrast(101%);
|
||||
}
|
||||
|
||||
.floating-message {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
@@ -1932,9 +2521,9 @@
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 300;
|
||||
/* Smart 3-line clamp */
|
||||
/* Smart 2-line clamp */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -2256,6 +2845,88 @@
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* MusicBrainz Relationships */
|
||||
.mb-relationships-loading {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.mb-relationships {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mb-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mb-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.mb-members-list,
|
||||
.mb-groups-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mb-member,
|
||||
.mb-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.mb-member:hover,
|
||||
.mb-group:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.mb-member.past {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.mb-member.past:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mb-member-icon,
|
||||
.mb-group-icon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.mb-member-name,
|
||||
.mb-group-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mb-member-role {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: transparent;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
id: string;
|
||||
title: string;
|
||||
artist: { id: number; name: string };
|
||||
genre?: { name: string };
|
||||
image: { small?: string; thumbnail?: string; large?: string };
|
||||
release_date_original?: string;
|
||||
hires: boolean;
|
||||
@@ -558,6 +559,10 @@
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getGenreLabel(album: FavoriteAlbum): string {
|
||||
return album.genre?.name || 'Unknown genre'
|
||||
}
|
||||
|
||||
function getQualityLabel(item: { hires?: boolean; maximum_bit_depth?: number; maximum_sampling_rate?: number }): string {
|
||||
if (item.hires && item.maximum_bit_depth && item.maximum_sampling_rate) {
|
||||
return `${item.maximum_bit_depth}bit/${item.maximum_sampling_rate}kHz`;
|
||||
@@ -1295,6 +1300,7 @@
|
||||
artwork={album.image?.large || album.image?.thumbnail || ''}
|
||||
title={album.title}
|
||||
artist={album.artist.name}
|
||||
genre={getGenreLabel(album)}
|
||||
size="large"
|
||||
quality={getQualityLabel(album)}
|
||||
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
|
||||
@@ -1367,6 +1373,7 @@
|
||||
artwork={album.image?.large || album.image?.thumbnail || ''}
|
||||
title={album.title}
|
||||
artist={album.artist.name}
|
||||
genre={getGenreLabel(album)}
|
||||
size="large"
|
||||
quality={getQualityLabel(album)}
|
||||
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
artwork: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
genre: string;
|
||||
quality?: string;
|
||||
}
|
||||
|
||||
@@ -332,6 +333,7 @@
|
||||
artwork: getQobuzImage(album.image),
|
||||
title: album.title,
|
||||
artist: album.artist?.name || 'Unknown Artist',
|
||||
genre: album.genre?.name || 'Unknown genre',
|
||||
quality: formatQuality(album.hires_streamable, album.maximum_bit_depth, album.maximum_sampling_rate)
|
||||
};
|
||||
}
|
||||
@@ -644,6 +646,7 @@
|
||||
artwork={album.artwork}
|
||||
title={album.title}
|
||||
artist={album.artist}
|
||||
genre={album.genre}
|
||||
size="large"
|
||||
quality={album.quality}
|
||||
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
|
||||
@@ -674,6 +677,7 @@
|
||||
artwork={album.artwork}
|
||||
title={album.title}
|
||||
artist={album.artist}
|
||||
genre={album.genre}
|
||||
size="large"
|
||||
quality={album.quality}
|
||||
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
|
||||
@@ -704,6 +708,7 @@
|
||||
artwork={album.artwork}
|
||||
title={album.title}
|
||||
artist={album.artist}
|
||||
genre={album.genre}
|
||||
size="large"
|
||||
quality={album.quality}
|
||||
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
|
||||
@@ -734,6 +739,7 @@
|
||||
artwork={album.artwork}
|
||||
title={album.title}
|
||||
artist={album.artist}
|
||||
genre={album.genre}
|
||||
size="large"
|
||||
quality={album.quality}
|
||||
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
|
||||
@@ -764,6 +770,7 @@
|
||||
artwork={album.artwork}
|
||||
title={album.title}
|
||||
artist={album.artist}
|
||||
genre={album.genre}
|
||||
size="large"
|
||||
quality={album.quality}
|
||||
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
|
||||
@@ -794,6 +801,7 @@
|
||||
artwork={album.artwork}
|
||||
title={album.title}
|
||||
artist={album.artist}
|
||||
genre={album.genre}
|
||||
size="large"
|
||||
quality={album.quality}
|
||||
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
|
||||
@@ -895,6 +903,7 @@
|
||||
artwork={album.artwork}
|
||||
title={album.title}
|
||||
artist={album.artist}
|
||||
genre={album.genre}
|
||||
size="large"
|
||||
quality={album.quality}
|
||||
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { onMount } from 'svelte';
|
||||
import { ArrowLeft, Search, X, Disc3, Loader2 } from 'lucide-svelte';
|
||||
import AlbumCard from '../AlbumCard.svelte';
|
||||
import type { QobuzAlbum, LabelDetail } from '$lib/types';
|
||||
import type { OfflineCacheStatus } from '$lib/stores/offlineCacheState';
|
||||
|
||||
interface Props {
|
||||
labelId: number;
|
||||
labelName?: string;
|
||||
onBack: () => void;
|
||||
onAlbumClick?: (albumId: string) => void;
|
||||
onAlbumPlay?: (albumId: string) => void;
|
||||
onAlbumPlayNext?: (albumId: string) => void;
|
||||
onAlbumPlayLater?: (albumId: string) => void;
|
||||
onAddAlbumToPlaylist?: (albumId: string) => void;
|
||||
onAlbumShareQobuz?: (albumId: string) => void;
|
||||
onAlbumShareSonglink?: (albumId: string) => void;
|
||||
onAlbumDownload?: (albumId: string) => void;
|
||||
onOpenAlbumFolder?: (albumId: string) => void;
|
||||
onReDownloadAlbum?: (albumId: string) => void;
|
||||
checkAlbumFullyDownloaded?: (albumId: string) => Promise<boolean>;
|
||||
downloadStateVersion?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
labelId,
|
||||
labelName = '',
|
||||
onBack,
|
||||
onAlbumClick,
|
||||
onAlbumPlay,
|
||||
onAlbumPlayNext,
|
||||
onAlbumPlayLater,
|
||||
onAddAlbumToPlaylist,
|
||||
onAlbumShareQobuz,
|
||||
onAlbumShareSonglink,
|
||||
onAlbumDownload,
|
||||
onOpenAlbumFolder,
|
||||
onReDownloadAlbum,
|
||||
checkAlbumFullyDownloaded,
|
||||
downloadStateVersion
|
||||
}: Props = $props();
|
||||
|
||||
// State
|
||||
let label = $state<LabelDetail | null>(null);
|
||||
let albums = $state<QobuzAlbum[]>([]);
|
||||
let loading = $state(false);
|
||||
let loadingMore = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let searchQuery = $state('');
|
||||
let searchExpanded = $state(false);
|
||||
let totalAlbums = $state(0);
|
||||
let albumsFetched = $state(0);
|
||||
|
||||
// Download status tracking
|
||||
let albumOfflineCacheStatuses = $state<Map<string, boolean>>(new Map());
|
||||
|
||||
// Derived: filtered albums based on search
|
||||
let filteredAlbums = $derived(
|
||||
searchQuery.trim()
|
||||
? albums.filter(album =>
|
||||
album.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
album.artist?.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: albums
|
||||
);
|
||||
|
||||
async function loadLabel() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await invoke<{
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
image?: { small?: string; thumbnail?: string; large?: string };
|
||||
albums?: { items: QobuzAlbum[]; total: number; offset: number; limit: number };
|
||||
albums_count?: number;
|
||||
}>('get_label', { labelId, limit: 100, offset: 0 });
|
||||
|
||||
label = {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
description: result.description,
|
||||
image: result.image,
|
||||
albums: result.albums?.items ?? [],
|
||||
totalAlbums: result.albums?.total ?? result.albums_count ?? 0,
|
||||
albumsFetched: result.albums?.items?.length ?? 0
|
||||
};
|
||||
|
||||
albums = label.albums;
|
||||
totalAlbums = label.totalAlbums;
|
||||
albumsFetched = label.albumsFetched;
|
||||
|
||||
// Load download statuses
|
||||
await loadAllAlbumOfflineCacheStatuses(albums);
|
||||
} catch (e) {
|
||||
console.error('Failed to load label:', e);
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore || albumsFetched >= totalAlbums) return;
|
||||
|
||||
loadingMore = true;
|
||||
|
||||
try {
|
||||
const result = await invoke<{
|
||||
id: number;
|
||||
name: string;
|
||||
albums?: { items: QobuzAlbum[]; total: number; offset: number; limit: number };
|
||||
}>('get_label', { labelId, limit: 100, offset: albumsFetched });
|
||||
|
||||
const newAlbums = result.albums?.items ?? [];
|
||||
albums = [...albums, ...newAlbums];
|
||||
albumsFetched += newAlbums.length;
|
||||
|
||||
// Load download statuses for new albums
|
||||
await loadAllAlbumOfflineCacheStatuses(newAlbums);
|
||||
} catch (e) {
|
||||
console.error('Failed to load more albums:', e);
|
||||
} finally {
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAlbumOfflineCacheStatus(albumId: string) {
|
||||
if (!checkAlbumFullyDownloaded) return false;
|
||||
try {
|
||||
const isDownloaded = await checkAlbumFullyDownloaded(albumId);
|
||||
albumOfflineCacheStatuses.set(albumId, isDownloaded);
|
||||
return isDownloaded;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllAlbumOfflineCacheStatuses(albumList: QobuzAlbum[]) {
|
||||
if (!checkAlbumFullyDownloaded || albumList.length === 0) return;
|
||||
await Promise.all(albumList.map(album => loadAlbumOfflineCacheStatus(album.id)));
|
||||
}
|
||||
|
||||
function isAlbumDownloaded(albumId: string): boolean {
|
||||
void downloadStateVersion;
|
||||
return albumOfflineCacheStatuses.get(albumId) || false;
|
||||
}
|
||||
|
||||
function getQualityLabel(album: QobuzAlbum): string {
|
||||
if (album.hires_streamable) {
|
||||
const bitDepth = album.maximum_bit_depth || 24;
|
||||
const sampleRate = album.maximum_sampling_rate || 96;
|
||||
return `${bitDepth}-bit/${sampleRate}kHz`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function getGenreLabel(album: QobuzAlbum): string {
|
||||
return album.genre?.name || '';
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery = '';
|
||||
}
|
||||
|
||||
function toggleSearch() {
|
||||
searchExpanded = !searchExpanded;
|
||||
if (!searchExpanded) {
|
||||
searchQuery = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll handler for infinite loading
|
||||
function handleScroll(e: Event) {
|
||||
const target = e.target as HTMLElement;
|
||||
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||
|
||||
if (scrollBottom < 400 && !loadingMore && albumsFetched < totalAlbums) {
|
||||
loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadLabel();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="label-view" onscroll={handleScroll}>
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<button class="back-btn" onclick={onBack} title="Go back">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div class="header-icon">
|
||||
<Disc3 size={36} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h1>{label?.name || labelName || 'Label'}</h1>
|
||||
<p class="subtitle">
|
||||
{#if loading}
|
||||
Loading...
|
||||
{:else if totalAlbums > 0}
|
||||
{totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Fixed Navigation/Search Bar -->
|
||||
<nav class="label-nav">
|
||||
<div class="nav-left">
|
||||
<span class="nav-title">Albums</span>
|
||||
{#if albumsFetched > 0 && albumsFetched < totalAlbums}
|
||||
<span class="nav-count">Showing {albumsFetched} of {totalAlbums}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
{#if searchExpanded}
|
||||
<div class="search-expanded">
|
||||
<Search size={16} class="search-icon-inline" />
|
||||
<input
|
||||
type="text"
|
||||
class="search-input-inline"
|
||||
placeholder="Search albums..."
|
||||
bind:value={searchQuery}
|
||||
autofocus
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button class="search-clear-btn" onclick={clearSearch}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
<button class="search-close-btn" onclick={toggleSearch}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="search-icon-btn" onclick={toggleSearch} title="Search albums">
|
||||
<Search size={18} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
{#if loading}
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading label...</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error">
|
||||
<p>Failed to load label</p>
|
||||
<p class="error-detail">{error}</p>
|
||||
<button class="retry-btn" onclick={loadLabel}>Retry</button>
|
||||
</div>
|
||||
{:else if albums.length === 0}
|
||||
<div class="empty">
|
||||
<Disc3 size={48} />
|
||||
<p>No albums found for this label</p>
|
||||
</div>
|
||||
{:else if filteredAlbums.length === 0}
|
||||
<div class="empty">
|
||||
<Search size={48} />
|
||||
<p>No albums match "{searchQuery}"</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="album-grid">
|
||||
{#each filteredAlbums as album (album.id)}
|
||||
<AlbumCard
|
||||
albumId={album.id}
|
||||
artwork={album.image?.large || album.image?.thumbnail || ''}
|
||||
title={album.title}
|
||||
artist={album.artist?.name || 'Unknown Artist'}
|
||||
genre={getGenreLabel(album)}
|
||||
quality={getQualityLabel(album)}
|
||||
size="large"
|
||||
onclick={() => onAlbumClick?.(album.id)}
|
||||
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
|
||||
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
|
||||
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
|
||||
onAddAlbumToPlaylist={onAddAlbumToPlaylist ? () => onAddAlbumToPlaylist(album.id) : undefined}
|
||||
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
|
||||
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
|
||||
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
|
||||
isAlbumFullyDownloaded={isAlbumDownloaded(album.id)}
|
||||
onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined}
|
||||
onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined}
|
||||
{downloadStateVersion}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if loadingMore}
|
||||
<div class="loading-more">
|
||||
<Loader2 size={20} class="spinner-icon" />
|
||||
<span>Loading more albums...</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.label-view {
|
||||
padding: 24px;
|
||||
padding-left: 18px;
|
||||
padding-right: 8px;
|
||||
padding-bottom: 100px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.label-view::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.label-view::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.label-view::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.label-view::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
border-radius: 16px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.label-nav {
|
||||
position: sticky;
|
||||
top: -24px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 24px;
|
||||
margin: 0 -8px 16px -24px;
|
||||
width: calc(100% + 32px);
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--alpha-6);
|
||||
box-shadow: 0 4px 8px -4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.nav-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search-icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.search-icon-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.search-expanded {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
:global(.search-icon-inline) {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input-inline {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-input-inline::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.search-clear-btn,
|
||||
.search-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 150ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-clear-btn:hover,
|
||||
.search-close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px 24px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading p,
|
||||
.error p,
|
||||
.empty p {
|
||||
margin: 16px 0 0 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--bg-tertiary);
|
||||
border-top-color: var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-detail {
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
margin-top: 16px;
|
||||
padding: 8px 24px;
|
||||
background-color: var(--accent-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.album-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px 14px;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:global(.spinner-icon) {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,468 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Musician Page View
|
||||
*
|
||||
* Full page view for musicians with confidence level 2 (Contextual).
|
||||
* Shows musician info and "Appears On" albums from Qobuz.
|
||||
*
|
||||
* This page:
|
||||
* - Is rendered only for confidence >= 2
|
||||
* - Shows bands/projects from MusicBrainz (if available)
|
||||
* - Shows "Appears On" albums from Qobuz ONLY
|
||||
* - Does NOT invent discographies
|
||||
* - Does NOT show raw search results
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ArrowLeft, User, Music, Disc } from 'lucide-svelte';
|
||||
import type { ResolvedMusician, AlbumAppearance, MusicianAppearances } from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
musician: ResolvedMusician;
|
||||
onBack: () => void;
|
||||
onAlbumClick: (albumId: string) => void;
|
||||
onArtistClick?: (artistId: number) => void;
|
||||
}
|
||||
|
||||
let { musician, onBack, onAlbumClick, onArtistClick }: Props = $props();
|
||||
|
||||
// State
|
||||
let appearances = $state<AlbumAppearance[]>([]);
|
||||
let totalAppearances = $state(0);
|
||||
let isLoading = $state(true);
|
||||
let isLoadingMore = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
let offset = $state(0);
|
||||
|
||||
const hasMore = $derived(appearances.length < totalAppearances);
|
||||
|
||||
onMount(() => {
|
||||
loadAppearances();
|
||||
});
|
||||
|
||||
async function loadAppearances() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await invoke<MusicianAppearances>('get_musician_appearances', {
|
||||
name: musician.name,
|
||||
role: musician.role,
|
||||
limit: ITEMS_PER_PAGE,
|
||||
offset: 0
|
||||
});
|
||||
|
||||
appearances = result.albums;
|
||||
totalAppearances = result.total;
|
||||
offset = result.albums.length;
|
||||
} catch (err) {
|
||||
console.error('Failed to load musician appearances:', err);
|
||||
error = 'Failed to load appearances';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (isLoadingMore || !hasMore) return;
|
||||
|
||||
isLoadingMore = true;
|
||||
|
||||
try {
|
||||
const result = await invoke<MusicianAppearances>('get_musician_appearances', {
|
||||
name: musician.name,
|
||||
role: musician.role,
|
||||
limit: ITEMS_PER_PAGE,
|
||||
offset
|
||||
});
|
||||
|
||||
appearances = [...appearances, ...result.albums];
|
||||
offset += result.albums.length;
|
||||
} catch (err) {
|
||||
console.error('Failed to load more appearances:', err);
|
||||
} finally {
|
||||
isLoadingMore = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="musician-page">
|
||||
<!-- Header -->
|
||||
<header class="page-header">
|
||||
<button class="back-btn" onclick={onBack} title="Go back">
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
|
||||
<div class="musician-header">
|
||||
<div class="musician-avatar">
|
||||
<User size={32} />
|
||||
</div>
|
||||
<div class="musician-info">
|
||||
<h1>{musician.name}</h1>
|
||||
<span class="role">{musician.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page-content">
|
||||
<!-- Bands & Projects Section -->
|
||||
{#if musician.bands.length > 0}
|
||||
<section class="section">
|
||||
<h2 class="section-title">Bands & Projects</h2>
|
||||
<div class="bands-grid">
|
||||
{#each musician.bands as band}
|
||||
<button
|
||||
class="band-card"
|
||||
onclick={() => {
|
||||
// TODO: Search for band as artist
|
||||
console.log('Search for band:', band);
|
||||
}}
|
||||
>
|
||||
<div class="band-icon">
|
||||
<Music size={16} />
|
||||
</div>
|
||||
<span class="band-name">{band}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Appears On Section -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">
|
||||
Appears On
|
||||
{#if totalAppearances > 0}
|
||||
<span class="count">({totalAppearances})</span>
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading-state">
|
||||
<span>Loading appearances...</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error-state">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{:else if appearances.length === 0}
|
||||
<div class="empty-state">
|
||||
<Disc size={32} />
|
||||
<span>No album appearances found</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="albums-grid">
|
||||
{#each appearances as album}
|
||||
<button
|
||||
class="album-card"
|
||||
onclick={() => onAlbumClick(album.album_id)}
|
||||
>
|
||||
{#if album.album_artwork}
|
||||
<img
|
||||
src={album.album_artwork}
|
||||
alt={album.album_title}
|
||||
class="album-artwork"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="album-artwork placeholder">
|
||||
<Disc size={24} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="album-info">
|
||||
<span class="album-title" title={album.album_title}>
|
||||
{album.album_title}
|
||||
</span>
|
||||
<span class="album-artist" title={album.artist_name}>
|
||||
{album.artist_name}
|
||||
</span>
|
||||
<div class="album-meta">
|
||||
{#if album.year}
|
||||
<span class="year">{album.year}</span>
|
||||
{/if}
|
||||
<span class="role-badge">{album.role_on_album}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if hasMore}
|
||||
<div class="load-more">
|
||||
<button
|
||||
class="load-more-btn"
|
||||
onclick={loadMore}
|
||||
disabled={isLoadingMore}
|
||||
>
|
||||
{isLoadingMore ? 'Loading...' : 'Load More'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.musician-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 24px 24px 16px;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-primary);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.musician-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.musician-avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.musician-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.musician-info h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.role {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-title .count {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Bands Grid */
|
||||
.bands-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.band-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.band-card:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.band-icon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.band-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Albums Grid */
|
||||
.albums-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.album-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.album-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.album-artwork {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.album-artwork.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.album-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.album-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.album-artist {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.album-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.year {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 10px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
/* States */
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Load More */
|
||||
.load-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
padding: 10px 24px;
|
||||
background: var(--bg-tertiary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.load-more-btn:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.load-more-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@
|
||||
import { Search, Disc3, Music, Mic2, User, X, ChevronLeft, ChevronRight, Crown } from 'lucide-svelte';
|
||||
import AlbumCard from '../AlbumCard.svelte';
|
||||
import TrackMenu from '../TrackMenu.svelte';
|
||||
import { getSearchState, setSearchState, type SearchResults, type SearchAllResults, type SearchTab } from '$lib/stores/searchState';
|
||||
import { getSearchState, setSearchState, type SearchResults, type SearchAllResults, type SearchTab, type SearchFilterType } from '$lib/stores/searchState';
|
||||
import { setPlaybackContext } from '$lib/stores/playbackContextStore';
|
||||
import { togglePlay } from '$lib/stores/playerStore';
|
||||
import { t } from '$lib/i18n';
|
||||
@@ -140,6 +140,7 @@
|
||||
id: string;
|
||||
title: string;
|
||||
artist: { name: string };
|
||||
genre?: { name: string; };
|
||||
image: { small?: string; thumbnail?: string; large?: string };
|
||||
release_date_original?: string;
|
||||
hires_streamable?: boolean;
|
||||
@@ -174,6 +175,7 @@
|
||||
|
||||
let query = $state(cachedState.query ?? '');
|
||||
let activeTab = $state<SearchTab>(cachedState.activeTab ?? 'all');
|
||||
let filterType = $state<SearchFilterType>(cachedState.filterType ?? null);
|
||||
let isSearching = $state(false);
|
||||
let searchError = $state<string | null>(null);
|
||||
|
||||
@@ -193,10 +195,13 @@
|
||||
|
||||
function debounceSearch() {
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
// Reset filter when user types a new search
|
||||
filterType = null;
|
||||
if (query.trim().length < 2) {
|
||||
albumResults = null;
|
||||
trackResults = null;
|
||||
artistResults = null;
|
||||
allResults = null;
|
||||
return;
|
||||
}
|
||||
searchTimeout = setTimeout(() => performSearch(), 300);
|
||||
@@ -208,6 +213,7 @@
|
||||
searchTimeout = null;
|
||||
}
|
||||
query = '';
|
||||
filterType = null;
|
||||
searchError = null;
|
||||
isSearching = false;
|
||||
albumResults = null;
|
||||
@@ -231,6 +237,7 @@
|
||||
setSearchState<Album, Track, Artist>({
|
||||
query,
|
||||
activeTab,
|
||||
filterType,
|
||||
albumResults,
|
||||
trackResults,
|
||||
artistResults,
|
||||
@@ -281,7 +288,8 @@
|
||||
// Search based on active tab - reset to first page
|
||||
if (activeTab === 'all') {
|
||||
allResults = await invoke<SearchAllResults<Album, Track, Artist>>('search_all', {
|
||||
query: query.trim()
|
||||
query: query.trim(),
|
||||
searchType: filterType
|
||||
});
|
||||
console.log('All results:', allResults);
|
||||
if (allResults && allResults.albums.items) {
|
||||
@@ -291,7 +299,8 @@
|
||||
albumResults = await invoke<SearchResults<Album>>('search_albums', {
|
||||
query: query.trim(),
|
||||
limit: PAGE_SIZE,
|
||||
offset: 0
|
||||
offset: 0,
|
||||
searchType: filterType
|
||||
});
|
||||
console.log('Album results:', albumResults);
|
||||
if (albumResults && albumResults.items) {
|
||||
@@ -301,14 +310,16 @@
|
||||
trackResults = await invoke<SearchResults<Track>>('search_tracks', {
|
||||
query: query.trim(),
|
||||
limit: PAGE_SIZE,
|
||||
offset: 0
|
||||
offset: 0,
|
||||
searchType: filterType
|
||||
});
|
||||
console.log('Track results:', trackResults);
|
||||
} else if (activeTab === 'artists') {
|
||||
artistResults = await invoke<SearchResults<Artist>>('search_artists', {
|
||||
query: query.trim(),
|
||||
limit: PAGE_SIZE,
|
||||
offset: 0
|
||||
offset: 0,
|
||||
searchType: filterType
|
||||
});
|
||||
console.log('Artist results:', artistResults);
|
||||
}
|
||||
@@ -331,7 +342,8 @@
|
||||
const moreResults = await invoke<SearchResults<Album>>('search_albums', {
|
||||
query: query.trim(),
|
||||
limit: PAGE_SIZE,
|
||||
offset: newOffset
|
||||
offset: newOffset,
|
||||
searchType: filterType
|
||||
});
|
||||
await loadAllAlbumDownloadStatuses(moreResults.items);
|
||||
albumResults = {
|
||||
@@ -344,7 +356,8 @@
|
||||
const moreResults = await invoke<SearchResults<Track>>('search_tracks', {
|
||||
query: query.trim(),
|
||||
limit: PAGE_SIZE,
|
||||
offset: newOffset
|
||||
offset: newOffset,
|
||||
searchType: filterType
|
||||
});
|
||||
trackResults = {
|
||||
...moreResults,
|
||||
@@ -356,7 +369,8 @@
|
||||
const moreResults = await invoke<SearchResults<Artist>>('search_artists', {
|
||||
query: query.trim(),
|
||||
limit: PAGE_SIZE,
|
||||
offset: newOffset
|
||||
offset: newOffset,
|
||||
searchType: filterType
|
||||
});
|
||||
artistResults = {
|
||||
...moreResults,
|
||||
@@ -378,12 +392,36 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Filter functions
|
||||
function setFilter(type: SearchFilterType) {
|
||||
filterType = type;
|
||||
if (query.trim().length >= 2) {
|
||||
performSearch();
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilter() {
|
||||
filterType = null;
|
||||
if (query.trim().length >= 2) {
|
||||
performSearch();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have any results to show filters
|
||||
let hasResults = $derived(
|
||||
!!(allResults || albumResults || trackResults || artistResults)
|
||||
);
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getGenreLabel(album: Album): string {
|
||||
return album.genre?.name || 'Unknown genre'
|
||||
}
|
||||
|
||||
function getQualityLabel(track: Track | Album): string {
|
||||
if (track.hires_streamable && track.maximum_bit_depth && track.maximum_sampling_rate) {
|
||||
return `${track.maximum_bit_depth}bit/${track.maximum_sampling_rate}kHz`;
|
||||
@@ -532,55 +570,102 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'all'}
|
||||
onclick={() => handleTabChange('all')}
|
||||
>
|
||||
<Search size={18} />
|
||||
<span>All</span>
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'albums'}
|
||||
onclick={() => handleTabChange('albums')}
|
||||
>
|
||||
<Disc3 size={18} />
|
||||
<span>{$t('search.albums')}</span>
|
||||
{#if albumResults}
|
||||
<span class="count">{albumResults.total}</span>
|
||||
{:else if allResults}
|
||||
<span class="count">{allResults.albums.total}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'tracks'}
|
||||
onclick={() => handleTabChange('tracks')}
|
||||
>
|
||||
<Music size={18} />
|
||||
<span>{$t('search.tracks')}</span>
|
||||
{#if trackResults}
|
||||
<span class="count">{trackResults.total}</span>
|
||||
{:else if allResults}
|
||||
<span class="count">{allResults.tracks.total}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'artists'}
|
||||
onclick={() => handleTabChange('artists')}
|
||||
>
|
||||
<Mic2 size={18} />
|
||||
<span>{$t('search.artists')}</span>
|
||||
{#if artistResults}
|
||||
<span class="count">{artistResults.total}</span>
|
||||
{:else if allResults}
|
||||
<span class="count">{allResults.artists.total}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Tabs and Filters Row -->
|
||||
<div class="tabs-row">
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'all'}
|
||||
onclick={() => handleTabChange('all')}
|
||||
>
|
||||
<Search size={18} />
|
||||
<span>All</span>
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'albums'}
|
||||
onclick={() => handleTabChange('albums')}
|
||||
>
|
||||
<Disc3 size={18} />
|
||||
<span>{$t('search.albums')}</span>
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'tracks'}
|
||||
onclick={() => handleTabChange('tracks')}
|
||||
>
|
||||
<Music size={18} />
|
||||
<span>{$t('search.tracks')}</span>
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'artists'}
|
||||
onclick={() => handleTabChange('artists')}
|
||||
>
|
||||
<Mic2 size={18} />
|
||||
<span>{$t('search.artists')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if hasResults}
|
||||
<div class="filters">
|
||||
<label class="filter-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="searchFilter"
|
||||
checked={filterType === 'MainArtist'}
|
||||
onchange={() => setFilter('MainArtist')}
|
||||
/>
|
||||
<span>Artist</span>
|
||||
</label>
|
||||
<label class="filter-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="searchFilter"
|
||||
checked={filterType === 'Performer'}
|
||||
onchange={() => setFilter('Performer')}
|
||||
/>
|
||||
<span>Performer</span>
|
||||
</label>
|
||||
<label class="filter-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="searchFilter"
|
||||
checked={filterType === 'Composer'}
|
||||
onchange={() => setFilter('Composer')}
|
||||
/>
|
||||
<span>Composer</span>
|
||||
</label>
|
||||
<label class="filter-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="searchFilter"
|
||||
checked={filterType === 'Label'}
|
||||
onchange={() => setFilter('Label')}
|
||||
/>
|
||||
<span>Label</span>
|
||||
</label>
|
||||
<label class="filter-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="searchFilter"
|
||||
checked={filterType === 'ReleaseName'}
|
||||
onchange={() => setFilter('ReleaseName')}
|
||||
/>
|
||||
<span>Release</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
class="clear-filter-btn"
|
||||
class:visible={filterType !== null}
|
||||
onclick={clearFilter}
|
||||
title="Clear filter"
|
||||
disabled={!filterType}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
@@ -640,6 +725,7 @@
|
||||
artwork={getAlbumArtwork(allResults.albums.items[0])}
|
||||
title={allResults.albums.items[0].title}
|
||||
artist={allResults.albums.items[0].artist?.name || 'Unknown Artist'}
|
||||
genre={getGenreLabel(allResults.albums.items[0])}
|
||||
size="large"
|
||||
quality={getQualityLabel(allResults.albums.items[0])}
|
||||
onPlay={onAlbumPlay ? () => onAlbumPlay(allResults.albums.items[0].id) : undefined}
|
||||
@@ -776,6 +862,7 @@
|
||||
artwork={getAlbumArtwork(album)}
|
||||
title={album.title}
|
||||
artist={album.artist?.name || 'Unknown Artist'}
|
||||
genre={getGenreLabel(album)}
|
||||
size="large"
|
||||
quality={getQualityLabel(album)}
|
||||
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
|
||||
@@ -915,6 +1002,7 @@
|
||||
artwork={getAlbumArtwork(album)}
|
||||
title={album.title}
|
||||
artist={album.artist?.name || 'Unknown Artist'}
|
||||
genre={getGenreLabel(album)}
|
||||
size="large"
|
||||
quality={getQualityLabel(album)}
|
||||
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
|
||||
@@ -1176,14 +1264,21 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
.tabs-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1209,11 +1304,61 @@
|
||||
border-bottom-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.tab .count {
|
||||
padding: 2px 8px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
/* Filters */
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.filter-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.filter-option input[type="radio"] {
|
||||
accent-color: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filter-option:has(input:checked) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filter-option:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.clear-filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-tertiary);
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.clear-filter-btn.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.clear-filter-btn.visible:hover {
|
||||
background: var(--bg-quaternary, var(--bg-tertiary));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.results {
|
||||
|
||||
@@ -106,6 +106,8 @@
|
||||
// Lyrics cache state
|
||||
let isClearingLyrics = $state(false);
|
||||
let lyricsCacheStats = $state<{ entries: number; sizeBytes: number } | null>(null);
|
||||
let isClearingMusicBrainz = $state(false);
|
||||
let musicBrainzCacheStats = $state<{ recordings: number; artists: number; releases: number; relations: number } | null>(null);
|
||||
|
||||
// Migration state
|
||||
let showMigrationModal = $state(false);
|
||||
@@ -248,6 +250,8 @@
|
||||
let selectedBackend = $state<string>('Auto');
|
||||
let selectedAlsaPlugin = $state<string>('hw (Direct Hardware)');
|
||||
let alsaHardwareVolume = $state(false);
|
||||
let streamFirstTrack = $state(false);
|
||||
let streamBufferSeconds = $state(3);
|
||||
|
||||
// Backend system state
|
||||
let availableBackends = $state<BackendInfo[]>([]);
|
||||
@@ -396,6 +400,17 @@
|
||||
let showLastfmConfig = $state(false);
|
||||
let hasEmbeddedCredentials = $state(false);
|
||||
|
||||
// MusicBrainz integration state
|
||||
let musicbrainzEnabled = $state(true);
|
||||
|
||||
// ListenBrainz integration state
|
||||
let listenbrainzConnected = $state(false);
|
||||
let listenbrainzUsername = $state('');
|
||||
let listenbrainzEnabled = $state(true);
|
||||
let listenbrainzToken = $state('');
|
||||
let listenbrainzConnecting = $state(false);
|
||||
let showListenBrainzConfig = $state(false);
|
||||
|
||||
// Load saved settings on mount
|
||||
onMount(() => {
|
||||
// Load theme
|
||||
@@ -451,6 +466,9 @@
|
||||
// Load lyrics cache stats
|
||||
loadLyricsCacheStats();
|
||||
|
||||
// Load MusicBrainz cache stats
|
||||
loadMusicBrainzCacheStats();
|
||||
|
||||
// Load audio devices first (includes PipeWire sinks), then settings
|
||||
// Also load backends and ALSA plugins
|
||||
Promise.all([
|
||||
@@ -462,6 +480,12 @@
|
||||
// Load Last.fm state
|
||||
loadLastfmState();
|
||||
|
||||
// Load MusicBrainz state
|
||||
loadMusicBrainzState();
|
||||
|
||||
// Load ListenBrainz state
|
||||
loadListenBrainzState();
|
||||
|
||||
// Load notification preferences
|
||||
loadToastsPreference();
|
||||
toastsEnabled = getToastsEnabled();
|
||||
@@ -658,6 +682,81 @@
|
||||
localStorage.setItem('qbz-lastfm-scrobbling', String(enabled));
|
||||
}
|
||||
|
||||
async function loadMusicBrainzState() {
|
||||
try {
|
||||
musicbrainzEnabled = await invoke<boolean>('musicbrainz_is_enabled');
|
||||
} catch (err) {
|
||||
console.error('Failed to load MusicBrainz state:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMusicBrainzChange(enabled: boolean) {
|
||||
try {
|
||||
await invoke('musicbrainz_set_enabled', { enabled });
|
||||
musicbrainzEnabled = enabled;
|
||||
} catch (err) {
|
||||
console.error('Failed to update MusicBrainz setting:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ListenBrainz functions
|
||||
async function loadListenBrainzState() {
|
||||
try {
|
||||
const status = await invoke<{
|
||||
connected: boolean;
|
||||
userName: string | null;
|
||||
enabled: boolean;
|
||||
}>('listenbrainz_get_status');
|
||||
listenbrainzConnected = status.connected;
|
||||
listenbrainzUsername = status.userName || '';
|
||||
listenbrainzEnabled = status.enabled;
|
||||
} catch (err) {
|
||||
console.error('Failed to load ListenBrainz state:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleListenBrainzConnect() {
|
||||
if (!listenbrainzToken.trim()) {
|
||||
showListenBrainzConfig = true;
|
||||
return;
|
||||
}
|
||||
|
||||
listenbrainzConnecting = true;
|
||||
try {
|
||||
const userInfo = await invoke<{ user_name: string }>('listenbrainz_connect', {
|
||||
token: listenbrainzToken.trim()
|
||||
});
|
||||
listenbrainzConnected = true;
|
||||
listenbrainzUsername = userInfo.user_name;
|
||||
listenbrainzToken = '';
|
||||
showListenBrainzConfig = false;
|
||||
} catch (err) {
|
||||
console.error('Failed to connect to ListenBrainz:', err);
|
||||
alert(`ListenBrainz error: ${err}`);
|
||||
} finally {
|
||||
listenbrainzConnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleListenBrainzDisconnect() {
|
||||
try {
|
||||
await invoke('listenbrainz_disconnect');
|
||||
listenbrainzConnected = false;
|
||||
listenbrainzUsername = '';
|
||||
} catch (err) {
|
||||
console.error('Failed to disconnect ListenBrainz:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleListenBrainzEnabledChange(enabled: boolean) {
|
||||
try {
|
||||
await invoke('listenbrainz_set_enabled', { enabled });
|
||||
listenbrainzEnabled = enabled;
|
||||
} catch (err) {
|
||||
console.error('Failed to update ListenBrainz setting:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShowDownloadsChange(enabled: boolean) {
|
||||
try {
|
||||
await invoke('set_show_downloads_in_library', { show: enabled });
|
||||
@@ -794,6 +893,8 @@
|
||||
backend_type: 'PipeWire' | 'Alsa' | 'Pulse' | null;
|
||||
alsa_plugin: 'Hw' | 'PlugHw' | 'Pcm' | null;
|
||||
alsa_hardware_volume: boolean;
|
||||
stream_first_track: boolean;
|
||||
stream_buffer_seconds: number;
|
||||
}
|
||||
|
||||
interface BackendInfo {
|
||||
@@ -939,6 +1040,8 @@
|
||||
}
|
||||
|
||||
alsaHardwareVolume = settings.alsa_hardware_volume ?? false;
|
||||
streamFirstTrack = settings.stream_first_track ?? false;
|
||||
streamBufferSeconds = settings.stream_buffer_seconds ?? 3;
|
||||
|
||||
// Validate mutual exclusion: DAC Passthrough disables Gapless + Crossfade
|
||||
if (dacPassthrough) {
|
||||
@@ -1105,6 +1208,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStreamFirstTrackChange(enabled: boolean) {
|
||||
streamFirstTrack = enabled;
|
||||
try {
|
||||
await invoke('set_audio_stream_first_track', { enabled });
|
||||
console.log('[Audio] Stream first track changed:', enabled);
|
||||
} catch (err) {
|
||||
console.error('[Audio] Failed to change stream first track:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStreamBufferSecondsChange(seconds: number) {
|
||||
// Clamp to valid range
|
||||
const clamped = Math.max(1, Math.min(10, Math.round(seconds)));
|
||||
streamBufferSeconds = clamped;
|
||||
try {
|
||||
await invoke('set_audio_stream_buffer_seconds', { seconds: clamped });
|
||||
console.log('[Audio] Stream buffer seconds changed:', clamped);
|
||||
} catch (err) {
|
||||
console.error('[Audio] Failed to change stream buffer seconds:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBackendDeviceChange(deviceName: string) {
|
||||
outputDevice = deviceName;
|
||||
|
||||
@@ -1372,6 +1497,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearMusicBrainzCache() {
|
||||
if (isClearingMusicBrainz) return;
|
||||
isClearingMusicBrainz = true;
|
||||
try {
|
||||
await invoke('musicbrainz_clear_cache');
|
||||
console.log('MusicBrainz cache cleared');
|
||||
await loadMusicBrainzCacheStats();
|
||||
} catch (err) {
|
||||
console.error('Failed to clear MusicBrainz cache:', err);
|
||||
} finally {
|
||||
isClearingMusicBrainz = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMusicBrainzCacheStats() {
|
||||
try {
|
||||
musicBrainzCacheStats = await invoke('musicbrainz_get_cache_stats');
|
||||
} catch (err) {
|
||||
console.error('Failed to load MusicBrainz cache stats:', err);
|
||||
musicBrainzCacheStats = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
@@ -1559,6 +1707,30 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Stream Uncached Tracks</span>
|
||||
<span class="setting-desc">Start playback faster when track is not in cache. Seeking may be limited during initial buffering.</span>
|
||||
</div>
|
||||
<Toggle enabled={streamFirstTrack} onchange={handleStreamFirstTrackChange} />
|
||||
</div>
|
||||
{#if streamFirstTrack}
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Initial Buffer Size</span>
|
||||
<span class="setting-desc">Seconds to buffer before starting playback ({streamBufferSeconds}s)</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="10"
|
||||
step="1"
|
||||
value={streamBufferSeconds}
|
||||
oninput={(e) => handleStreamBufferSecondsChange(parseInt(e.currentTarget.value))}
|
||||
class="buffer-slider"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="setting-row last">
|
||||
<span class="setting-label">{$t('settings.audio.currentSampleRate')}</span>
|
||||
<span class="setting-value" class:muted={!hardwareStatus?.is_active}>
|
||||
@@ -1918,6 +2090,75 @@
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- ListenBrainz Integration -->
|
||||
{#if listenbrainzConnected}
|
||||
<div class="setting-row">
|
||||
<div class="lastfm-connected">
|
||||
<span class="setting-label">ListenBrainz</span>
|
||||
<span class="lastfm-username">{$t('settings.integrations.connectedAs', { values: { username: listenbrainzUsername }})}</span>
|
||||
</div>
|
||||
<button
|
||||
class="connect-btn connected"
|
||||
onclick={handleListenBrainzDisconnect}
|
||||
>
|
||||
{$t('settings.integrations.disconnect')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<span class="setting-label">Scrobbling</span>
|
||||
<Toggle enabled={listenbrainzEnabled} onchange={handleListenBrainzEnabledChange} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="setting-row" class:last={!showListenBrainzConfig}>
|
||||
<span class="setting-label">ListenBrainz</span>
|
||||
<button
|
||||
class="connect-btn"
|
||||
onclick={() => showListenBrainzConfig = !showListenBrainzConfig}
|
||||
disabled={listenbrainzConnecting}
|
||||
>
|
||||
{listenbrainzConnecting ? 'Connecting...' : $t('settings.integrations.connect')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showListenBrainzConfig}
|
||||
<div class="lastfm-config">
|
||||
<p class="config-info">
|
||||
Get your personal token from
|
||||
<a href="https://listenbrainz.org/settings/" target="_blank" rel="noopener">
|
||||
listenbrainz.org/settings
|
||||
</a>
|
||||
</p>
|
||||
<div class="config-field">
|
||||
<label for="listenbrainz-token">User Token</label>
|
||||
<input
|
||||
id="listenbrainz-token"
|
||||
type="password"
|
||||
bind:value={listenbrainzToken}
|
||||
placeholder="Paste your ListenBrainz token"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="auth-start-btn"
|
||||
onclick={handleListenBrainzConnect}
|
||||
disabled={!listenbrainzToken.trim() || listenbrainzConnecting}
|
||||
>
|
||||
{listenbrainzConnecting ? 'Connecting...' : 'Connect'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- MusicBrainz Integration -->
|
||||
<div class="setting-row last">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">MusicBrainz</span>
|
||||
<small class="setting-note">
|
||||
Enable artist relationships and enhanced metadata from MusicBrainz.
|
||||
</small>
|
||||
</div>
|
||||
<Toggle enabled={musicbrainzEnabled} onchange={handleMusicBrainzChange} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Storage Section (Memory Cache) -->
|
||||
@@ -1949,7 +2190,7 @@
|
||||
{isClearing ? $t('settings.storage.clearing') : $t('actions.clear')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="setting-row last">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{$t('settings.lyrics.clearLyrics')}</span>
|
||||
<small class="setting-note">
|
||||
@@ -1973,6 +2214,25 @@
|
||||
{isClearingLyrics ? $t('settings.storage.clearing') : $t('actions.clear')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="setting-row last">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">MusicBrainz Cache</span>
|
||||
<small class="setting-note">
|
||||
{#if musicBrainzCacheStats}
|
||||
{musicBrainzCacheStats.artists} artists, {musicBrainzCacheStats.relations} relations, {musicBrainzCacheStats.recordings} recordings
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
class="clear-btn"
|
||||
onclick={handleClearMusicBrainzCache}
|
||||
disabled={isClearingMusicBrainz || !musicBrainzCacheStats || (musicBrainzCacheStats.artists === 0 && musicBrainzCacheStats.relations === 0 && musicBrainzCacheStats.recordings === 0)}
|
||||
>
|
||||
{isClearingMusicBrainz ? $t('settings.storage.clearing') : $t('actions.clear')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Flatpak Section (only shown when running in Flatpak) -->
|
||||
@@ -2735,6 +2995,41 @@ flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz</pre>
|
||||
color: rgb(251, 191, 36);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Buffer slider styling */
|
||||
.buffer-slider {
|
||||
width: 120px;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--alpha-20);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.buffer-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--accent-color, #3b82f6);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.buffer-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.buffer-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--accent-color, #3b82f6);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<MigrationModal
|
||||
|
||||
@@ -114,7 +114,7 @@ export async function playTrack(
|
||||
if (isLocal) {
|
||||
await invoke('library_play_track', { trackId: track.id });
|
||||
} else {
|
||||
await invoke('play_track', { trackId: track.id });
|
||||
await invoke('play_track', { trackId: track.id, durationSecs: track.duration });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,16 @@ export async function playTrack(
|
||||
// Update Last.fm
|
||||
await updateLastfmNowPlaying(track.title, track.artist, track.album, track.duration, track.id);
|
||||
|
||||
// Update ListenBrainz (with MusicBrainz enrichment)
|
||||
await updateListenBrainzNowPlaying(
|
||||
track.title,
|
||||
track.artist,
|
||||
track.album,
|
||||
track.duration,
|
||||
track.id,
|
||||
track.isrc
|
||||
);
|
||||
|
||||
// Check favorite status (only for Qobuz tracks)
|
||||
if (!isLocal) {
|
||||
const isFav = await checkTrackFavorite(track.id);
|
||||
@@ -289,6 +299,185 @@ export async function showTrackNotification(
|
||||
let lastScrobbledTrackId: number | null = null;
|
||||
let scrobbleTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// ============ ListenBrainz Integration ============
|
||||
|
||||
let lastListenBrainzScrobbledTrackId: number | null = null;
|
||||
let listenbrainzScrobbleTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
interface ListenBrainzStatus {
|
||||
connected: boolean;
|
||||
userName: string | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface MusicBrainzTrackData {
|
||||
mbid?: string;
|
||||
title?: string;
|
||||
artistCredit?: string;
|
||||
artistMbids?: string[];
|
||||
releaseMbid?: string;
|
||||
releaseTitle?: string;
|
||||
confidence: 'exact' | 'high' | 'medium' | 'low' | 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ListenBrainz connection status
|
||||
*/
|
||||
async function getListenBrainzStatus(): Promise<ListenBrainzStatus | null> {
|
||||
try {
|
||||
return await invoke<ListenBrainzStatus>('listenbrainz_get_status');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve track to MusicBrainz for enhanced scrobbling
|
||||
*/
|
||||
async function resolveMusicBrainzTrack(
|
||||
title: string,
|
||||
artist: string,
|
||||
isrc?: string
|
||||
): Promise<MusicBrainzTrackData | null> {
|
||||
try {
|
||||
const result = await invoke<MusicBrainzTrackData>('musicbrainz_resolve_track', {
|
||||
isrc: isrc || null,
|
||||
title,
|
||||
artist
|
||||
});
|
||||
if (result && result.confidence !== 'none') {
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update ListenBrainz "now playing" and schedule scrobble
|
||||
*/
|
||||
export async function updateListenBrainzNowPlaying(
|
||||
title: string,
|
||||
artist: string,
|
||||
album: string,
|
||||
durationSecs: number,
|
||||
trackId: number,
|
||||
isrc?: string
|
||||
): Promise<void> {
|
||||
// Check if ListenBrainz is connected and enabled
|
||||
const status = await getListenBrainzStatus();
|
||||
if (!status?.connected || !status?.enabled) return;
|
||||
|
||||
const isOffline = checkIsOffline();
|
||||
const durationMs = durationSecs * 1000;
|
||||
|
||||
// Try to get MusicBrainz data for enrichment
|
||||
let mbData: MusicBrainzTrackData | null = null;
|
||||
if (!isOffline) {
|
||||
mbData = await resolveMusicBrainzTrack(title, artist, isrc);
|
||||
}
|
||||
|
||||
// Skip "now playing" update when offline (requires network)
|
||||
if (!isOffline) {
|
||||
try {
|
||||
await invoke('listenbrainz_now_playing', {
|
||||
artist,
|
||||
track: title,
|
||||
album: album || null,
|
||||
recordingMbid: mbData?.mbid || null,
|
||||
releaseMbid: mbData?.releaseMbid || null,
|
||||
artistMbids: mbData?.artistMbids || null,
|
||||
isrc: isrc || null,
|
||||
durationMs
|
||||
});
|
||||
console.log('ListenBrainz: Updated now playing');
|
||||
} catch (err) {
|
||||
console.error('ListenBrainz now playing failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule scrobble after 50% of track or 4 minutes (whichever is shorter)
|
||||
if (listenbrainzScrobbleTimeout) {
|
||||
clearTimeout(listenbrainzScrobbleTimeout);
|
||||
}
|
||||
|
||||
const scrobbleDelay = Math.min(durationSecs * 0.5, 240) * 1000; // in ms
|
||||
|
||||
listenbrainzScrobbleTimeout = setTimeout(async () => {
|
||||
if (lastListenBrainzScrobbledTrackId !== trackId) {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
// If offline, queue the scrobble for later
|
||||
if (checkIsOffline()) {
|
||||
try {
|
||||
await invoke('listenbrainz_queue_listen', {
|
||||
artist,
|
||||
track: title,
|
||||
album: album || null,
|
||||
timestamp,
|
||||
recordingMbid: mbData?.mbid || null,
|
||||
releaseMbid: mbData?.releaseMbid || null,
|
||||
artistMbids: mbData?.artistMbids || null,
|
||||
isrc: isrc || null,
|
||||
durationMs
|
||||
});
|
||||
lastListenBrainzScrobbledTrackId = trackId;
|
||||
console.log('ListenBrainz: Queued scrobble for later (offline)');
|
||||
} catch (err) {
|
||||
console.error('Failed to queue ListenBrainz scrobble:', err);
|
||||
}
|
||||
} else {
|
||||
// Online - scrobble immediately
|
||||
try {
|
||||
await invoke('listenbrainz_scrobble', {
|
||||
artist,
|
||||
track: title,
|
||||
album: album || null,
|
||||
timestamp,
|
||||
recordingMbid: mbData?.mbid || null,
|
||||
releaseMbid: mbData?.releaseMbid || null,
|
||||
artistMbids: mbData?.artistMbids || null,
|
||||
isrc: isrc || null,
|
||||
durationMs
|
||||
});
|
||||
lastListenBrainzScrobbledTrackId = trackId;
|
||||
console.log('ListenBrainz: Scrobbled track');
|
||||
} catch (err) {
|
||||
console.error('ListenBrainz scrobble failed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, scrobbleDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush queued ListenBrainz listens
|
||||
* Call this when transitioning from offline to online
|
||||
*/
|
||||
export async function flushListenBrainzQueue(): Promise<number> {
|
||||
// Check if ListenBrainz is connected and enabled
|
||||
const status = await getListenBrainzStatus();
|
||||
if (!status?.connected || !status?.enabled) return 0;
|
||||
|
||||
// Don't try to flush if still offline
|
||||
if (checkIsOffline()) {
|
||||
console.log('ListenBrainz: Cannot flush queue (still offline)');
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const sent = await invoke<number>('listenbrainz_flush_queue');
|
||||
if (sent > 0) {
|
||||
console.log(`ListenBrainz: Flushed ${sent} queued listens`);
|
||||
}
|
||||
return sent;
|
||||
} catch (err) {
|
||||
console.error('ListenBrainz: Failed to flush queue:', err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Last.fm "now playing" and schedule scrobble
|
||||
*/
|
||||
@@ -514,4 +703,8 @@ export function cleanup(): void {
|
||||
clearTimeout(scrobbleTimeout);
|
||||
scrobbleTimeout = null;
|
||||
}
|
||||
if (listenbrainzScrobbleTimeout) {
|
||||
clearTimeout(listenbrainzScrobbleTimeout);
|
||||
listenbrainzScrobbleTimeout = null;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export type ViewType =
|
||||
| 'settings'
|
||||
| 'album'
|
||||
| 'artist'
|
||||
| 'musician'
|
||||
| 'label'
|
||||
| 'playlist'
|
||||
| 'playlist-manager'
|
||||
| 'favorites-tracks'
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface PlayingTrack {
|
||||
// Optional IDs for recommendation tracking
|
||||
albumId?: string;
|
||||
artistId?: number;
|
||||
// ISRC for MusicBrainz/ListenBrainz enrichment
|
||||
isrc?: string;
|
||||
}
|
||||
|
||||
interface BackendPlaybackState {
|
||||
@@ -243,8 +245,8 @@ export async function togglePlay(): Promise<void> {
|
||||
console.log('[Player] Restoring local track:', localTrackId);
|
||||
await invoke('library_play_track', { trackId: localTrackId });
|
||||
} else {
|
||||
// Qobuz track - use play_track
|
||||
await invoke('play_track', { trackId: currentTrack.id });
|
||||
// Qobuz track - use play_track with duration for seekbar
|
||||
await invoke('play_track', { trackId: currentTrack.id, durationSecs: currentTrack.duration });
|
||||
}
|
||||
|
||||
// Seek to saved position after a short delay to let audio load
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export type SearchTab = 'all' | 'albums' | 'tracks' | 'artists';
|
||||
|
||||
// Search filter types supported by Qobuz API
|
||||
export type SearchFilterType = 'MainArtist' | 'Performer' | 'Composer' | 'Label' | 'ReleaseName' | null;
|
||||
|
||||
export interface SearchResults<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
@@ -16,6 +19,7 @@ export interface SearchAllResults<Album, Track, Artist> {
|
||||
export interface SearchState<Album, Track, Artist> {
|
||||
query: string;
|
||||
activeTab: SearchTab;
|
||||
filterType: SearchFilterType;
|
||||
albumResults: SearchResults<Album> | null;
|
||||
trackResults: SearchResults<Track> | null;
|
||||
artistResults: SearchResults<Artist> | null;
|
||||
@@ -25,6 +29,7 @@ export interface SearchState<Album, Track, Artist> {
|
||||
let searchState: SearchState<unknown, unknown, unknown> = {
|
||||
query: '',
|
||||
activeTab: 'all',
|
||||
filterType: null,
|
||||
albumResults: null,
|
||||
trackResults: null,
|
||||
artistResults: null,
|
||||
|
||||
@@ -81,3 +81,65 @@ export function collapseSidebar(): void {
|
||||
export function toggleSidebar(): void {
|
||||
setExpanded(!isExpanded);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Content Sidebars (Lyrics, Queue, Network)
|
||||
// Only one can be open at a time
|
||||
// ============================================
|
||||
|
||||
export type ContentSidebarType = 'lyrics' | 'queue' | 'network' | null;
|
||||
|
||||
let activeContentSidebar: ContentSidebarType = null;
|
||||
const contentSidebarListeners = new Set<(type: ContentSidebarType) => void>();
|
||||
|
||||
function notifyContentSidebarListeners(): void {
|
||||
for (const listener of contentSidebarListeners) {
|
||||
listener(activeContentSidebar);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to content sidebar changes
|
||||
*/
|
||||
export function subscribeContentSidebar(listener: (type: ContentSidebarType) => void): () => void {
|
||||
contentSidebarListeners.add(listener);
|
||||
listener(activeContentSidebar);
|
||||
return () => contentSidebarListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active content sidebar
|
||||
*/
|
||||
export function getActiveContentSidebar(): ContentSidebarType {
|
||||
return activeContentSidebar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a content sidebar (closes others)
|
||||
*/
|
||||
export function openContentSidebar(type: ContentSidebarType): void {
|
||||
activeContentSidebar = type;
|
||||
notifyContentSidebarListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a content sidebar
|
||||
*/
|
||||
export function closeContentSidebar(type: ContentSidebarType): void {
|
||||
if (activeContentSidebar === type) {
|
||||
activeContentSidebar = null;
|
||||
notifyContentSidebarListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a content sidebar
|
||||
*/
|
||||
export function toggleContentSidebar(type: ContentSidebarType): void {
|
||||
if (activeContentSidebar === type) {
|
||||
activeContentSidebar = null;
|
||||
} else {
|
||||
activeContentSidebar = type;
|
||||
}
|
||||
notifyContentSidebarListeners();
|
||||
}
|
||||
+70
-1
@@ -21,6 +21,8 @@ export interface QobuzTrack {
|
||||
id?: string;
|
||||
title: string;
|
||||
image?: QobuzImage;
|
||||
label?: { id: number; name: string };
|
||||
genre?: { name: string };
|
||||
};
|
||||
performer?: { id?: number; name: string };
|
||||
hires_streamable?: boolean;
|
||||
@@ -59,6 +61,7 @@ export interface AlbumInfo {
|
||||
year: string;
|
||||
release_date?: string;
|
||||
label: string;
|
||||
label_id?: number;
|
||||
genre: string;
|
||||
quality: string;
|
||||
track_count: number;
|
||||
@@ -90,7 +93,7 @@ export interface QobuzAlbum {
|
||||
hires_streamable?: boolean;
|
||||
tracks_count?: number;
|
||||
duration?: number;
|
||||
label?: { name: string };
|
||||
label?: { id?: number; name: string };
|
||||
genre?: { name: string };
|
||||
maximum_bit_depth?: number;
|
||||
maximum_sampling_rate?: number;
|
||||
@@ -161,6 +164,7 @@ export interface AlbumDetail {
|
||||
year: string;
|
||||
releaseDate?: string; // Full date in YYYY-MM-DD format
|
||||
label: string;
|
||||
labelId?: number;
|
||||
genre: string;
|
||||
quality: string;
|
||||
trackCount: number;
|
||||
@@ -185,6 +189,7 @@ export interface ArtistDetail {
|
||||
artwork: string;
|
||||
year?: string;
|
||||
quality: string;
|
||||
genre: string;
|
||||
}[];
|
||||
epsSingles: {
|
||||
id: string;
|
||||
@@ -192,6 +197,7 @@ export interface ArtistDetail {
|
||||
artwork: string;
|
||||
year?: string;
|
||||
quality: string;
|
||||
genre: string;
|
||||
}[];
|
||||
liveAlbums: {
|
||||
id: string;
|
||||
@@ -199,6 +205,7 @@ export interface ArtistDetail {
|
||||
artwork: string;
|
||||
year?: string;
|
||||
quality: string;
|
||||
genre: string;
|
||||
}[];
|
||||
compilations: {
|
||||
id: string;
|
||||
@@ -206,6 +213,7 @@ export interface ArtistDetail {
|
||||
artwork: string;
|
||||
year?: string;
|
||||
quality: string;
|
||||
genre: string;
|
||||
}[];
|
||||
tributes: {
|
||||
id: string;
|
||||
@@ -213,6 +221,7 @@ export interface ArtistDetail {
|
||||
artwork: string;
|
||||
year?: string;
|
||||
quality: string;
|
||||
genre: string;
|
||||
}[];
|
||||
others: {
|
||||
id: string;
|
||||
@@ -220,6 +229,7 @@ export interface ArtistDetail {
|
||||
artwork: string;
|
||||
year?: string;
|
||||
quality: string;
|
||||
genre: string;
|
||||
}[];
|
||||
playlists: {
|
||||
id: number;
|
||||
@@ -228,6 +238,20 @@ export interface ArtistDetail {
|
||||
trackCount?: number;
|
||||
owner?: string;
|
||||
}[];
|
||||
labels: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
totalAlbums: number;
|
||||
albumsFetched: number;
|
||||
}
|
||||
|
||||
export interface LabelDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
image?: QobuzImage;
|
||||
albums: QobuzAlbum[];
|
||||
totalAlbums: number;
|
||||
albumsFetched: number;
|
||||
}
|
||||
@@ -300,6 +324,51 @@ export interface SongLinkResponse {
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
// ============ Musician Types ============
|
||||
|
||||
/**
|
||||
* Musician confidence level for MusicBrainz ↔ Qobuz matching
|
||||
* Determines what UI is shown when a musician is clicked:
|
||||
* - confirmed (3): Navigate to Qobuz Artist Page
|
||||
* - contextual (2): Navigate to Musician Page
|
||||
* - weak (1): Show Informational Modal only
|
||||
* - none (0): Show Informational Modal only
|
||||
*/
|
||||
export type MusicianConfidence = 'confirmed' | 'contextual' | 'weak' | 'none';
|
||||
|
||||
/**
|
||||
* Resolved musician with confidence assessment
|
||||
*/
|
||||
export interface ResolvedMusician {
|
||||
name: string;
|
||||
role: string;
|
||||
mbid?: string;
|
||||
qobuz_artist_id?: number;
|
||||
confidence: MusicianConfidence;
|
||||
bands: string[];
|
||||
appears_on_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Album appearance for a musician
|
||||
*/
|
||||
export interface AlbumAppearance {
|
||||
album_id: string;
|
||||
album_title: string;
|
||||
album_artwork: string;
|
||||
artist_name: string;
|
||||
year?: string;
|
||||
role_on_album: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Musician appearances response
|
||||
*/
|
||||
export interface MusicianAppearances {
|
||||
albums: AlbumAppearance[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ============ Preferences Types ============
|
||||
|
||||
export interface FavoritesPreferences {
|
||||
|
||||
+137
-5
@@ -93,7 +93,7 @@
|
||||
// Favorites state management
|
||||
import { loadFavorites } from '$lib/stores/favoritesStore';
|
||||
import { getDefaultFavoritesTab } from '$lib/utils/favorites';
|
||||
import type { FavoritesPreferences } from '$lib/types';
|
||||
import type { FavoritesPreferences, ResolvedMusician } from '$lib/types';
|
||||
|
||||
// Navigation state management
|
||||
import {
|
||||
@@ -177,6 +177,7 @@
|
||||
Track,
|
||||
AlbumDetail,
|
||||
ArtistDetail,
|
||||
LabelDetail,
|
||||
PlaylistTrack,
|
||||
DisplayTrack,
|
||||
LocalLibraryTrack,
|
||||
@@ -241,10 +242,14 @@
|
||||
// MiniPlayer
|
||||
import { enterMiniplayerMode } from '$lib/services/miniplayerService';
|
||||
|
||||
// Sidebar mutual exclusion
|
||||
import { closeContentSidebar, subscribeContentSidebar, type ContentSidebarType } from '$lib/stores/sidebarStore';
|
||||
|
||||
// Lyrics state management
|
||||
import {
|
||||
subscribe as subscribeLyrics,
|
||||
toggleSidebar as toggleLyricsSidebar,
|
||||
hideSidebar as hideLyricsSidebar,
|
||||
startWatching as startLyricsWatching,
|
||||
stopWatching as stopLyricsWatching,
|
||||
startActiveLineUpdates,
|
||||
@@ -274,6 +279,8 @@
|
||||
import SettingsView from '$lib/components/views/SettingsView.svelte';
|
||||
import AlbumDetailView from '$lib/components/views/AlbumDetailView.svelte';
|
||||
import ArtistDetailView from '$lib/components/views/ArtistDetailView.svelte';
|
||||
import MusicianPageView from '$lib/components/views/MusicianPageView.svelte';
|
||||
import LabelView from '$lib/components/views/LabelView.svelte';
|
||||
import PlaylistDetailView from '$lib/components/views/PlaylistDetailView.svelte';
|
||||
import FavoritesView from '$lib/components/views/FavoritesView.svelte';
|
||||
import LocalLibraryView from '$lib/components/views/LocalLibraryView.svelte';
|
||||
@@ -287,6 +294,7 @@
|
||||
import PlaylistImportModal from '$lib/components/PlaylistImportModal.svelte';
|
||||
import TrackInfoModal from '$lib/components/TrackInfoModal.svelte';
|
||||
import AlbumCreditsModal from '$lib/components/AlbumCreditsModal.svelte';
|
||||
import MusicianModal from '$lib/components/MusicianModal.svelte';
|
||||
import CastPicker from '$lib/components/CastPicker.svelte';
|
||||
import LyricsSidebar from '$lib/components/lyrics/LyricsSidebar.svelte';
|
||||
import OfflinePlaceholder from '$lib/components/OfflinePlaceholder.svelte';
|
||||
@@ -318,13 +326,16 @@
|
||||
// View State (from navigationStore subscription)
|
||||
let activeView = $state<ViewType>('home');
|
||||
let selectedPlaylistId = $state<number | null>(null);
|
||||
// Album and Artist data are fetched, so kept local
|
||||
// Album, Artist and Label data are fetched, so kept local
|
||||
let selectedAlbum = $state<AlbumDetail | null>(null);
|
||||
let selectedArtist = $state<ArtistDetail | null>(null);
|
||||
let selectedLabel = $state<{ id: number; name: string } | null>(null);
|
||||
let selectedMusician = $state<ResolvedMusician | null>(null);
|
||||
let musicianModalData = $state<ResolvedMusician | null>(null);
|
||||
let isArtistAlbumsLoading = $state(false);
|
||||
|
||||
// Artist albums for "By the same artist" section in album view
|
||||
let albumArtistAlbums = $state<{ id: string; title: string; artwork: string; quality: string }[]>([]);
|
||||
let albumArtistAlbums = $state<{ id: string; title: string; artwork: string; quality: string; genre: string }[]>([]);
|
||||
|
||||
// Overlay States (from uiStore subscription)
|
||||
let isQueueOpen = $state(false);
|
||||
@@ -444,13 +455,15 @@
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
artwork: a.artwork,
|
||||
quality: a.quality
|
||||
quality: a.quality,
|
||||
genre: a.genre
|
||||
})),
|
||||
...artistDetail.liveAlbums.map(a => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
artwork: a.artwork,
|
||||
quality: a.quality
|
||||
quality: a.quality,
|
||||
genre: a.genre
|
||||
}))
|
||||
].slice(0, 16);
|
||||
|
||||
@@ -496,6 +509,66 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleLabelClick(labelId: number, labelName?: string) {
|
||||
selectedLabel = { id: labelId, name: labelName || '' };
|
||||
navigateTo('label');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle musician click from credits
|
||||
* Resolves musician and routes based on confidence level:
|
||||
* - Confirmed (3): Navigate to Qobuz Artist Page
|
||||
* - Contextual (2): Navigate to Musician Page
|
||||
* - Weak (1), None (0): Show Informational Modal
|
||||
*/
|
||||
async function handleMusicianClick(name: string, role: string) {
|
||||
showToast(`Looking up ${name}...`, 'info');
|
||||
try {
|
||||
const musician = await invoke<ResolvedMusician>('resolve_musician', { name, role });
|
||||
console.log('Resolved musician:', musician);
|
||||
|
||||
switch (musician.confidence) {
|
||||
case 'confirmed':
|
||||
// Has a Qobuz artist page - navigate there
|
||||
if (musician.qobuz_artist_id) {
|
||||
handleArtistClick(musician.qobuz_artist_id);
|
||||
} else {
|
||||
// Fallback: show modal
|
||||
musicianModalData = musician;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'contextual':
|
||||
// Show full Musician Page
|
||||
selectedMusician = musician;
|
||||
navigateTo('musician');
|
||||
break;
|
||||
|
||||
case 'weak':
|
||||
case 'none':
|
||||
default:
|
||||
// Show Informational Modal only
|
||||
musicianModalData = musician;
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to resolve musician:', err);
|
||||
showToast('Could not look up musician info', 'error');
|
||||
// Fallback: open modal with basic info
|
||||
musicianModalData = {
|
||||
name,
|
||||
role,
|
||||
confidence: 'none',
|
||||
bands: [],
|
||||
appears_on_count: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function closeMusicianModal() {
|
||||
musicianModalData = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a performer by name (from track credits)
|
||||
*/
|
||||
@@ -504,6 +577,7 @@
|
||||
setSearchState({
|
||||
query: name,
|
||||
activeTab: 'all',
|
||||
filterType: null,
|
||||
albumResults: null,
|
||||
trackResults: null,
|
||||
artistResults: null,
|
||||
@@ -2067,6 +2141,10 @@
|
||||
// Subscribe to UI state changes
|
||||
const unsubscribeUI = subscribeUI(() => {
|
||||
const uiState = getUIState();
|
||||
// Close network sidebar when queue opens
|
||||
if (uiState.isQueueOpen && !isQueueOpen) {
|
||||
closeContentSidebar('network');
|
||||
}
|
||||
isQueueOpen = uiState.isQueueOpen;
|
||||
isFullScreenOpen = uiState.isFullScreenOpen;
|
||||
isFocusModeOpen = uiState.isFocusModeOpen;
|
||||
@@ -2168,6 +2246,18 @@
|
||||
lyricsActiveIndex = state.activeIndex;
|
||||
lyricsActiveProgress = state.activeProgress;
|
||||
lyricsSidebarVisible = state.sidebarVisible;
|
||||
// Close network sidebar when lyrics opens
|
||||
if (state.sidebarVisible) {
|
||||
closeContentSidebar('network');
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to content sidebar for mutual exclusion (network closes lyrics/queue)
|
||||
const unsubscribeContentSidebar = subscribeContentSidebar((active: ContentSidebarType) => {
|
||||
if (active === 'network') {
|
||||
hideLyricsSidebar();
|
||||
closeQueue();
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to cast state changes
|
||||
@@ -2296,6 +2386,7 @@
|
||||
unsubscribePlayer();
|
||||
unsubscribeQueue();
|
||||
unsubscribeLyrics();
|
||||
unsubscribeContentSidebar();
|
||||
unsubscribeCast();
|
||||
stopLyricsWatching();
|
||||
stopActiveLineUpdates();
|
||||
@@ -2498,6 +2589,7 @@
|
||||
isPlaybackActive={isPlaying}
|
||||
onBack={navGoBack}
|
||||
onArtistClick={() => selectedAlbum?.artistId && handleArtistClick(selectedAlbum.artistId)}
|
||||
onLabelClick={handleLabelClick}
|
||||
onTrackPlay={handleAlbumTrackPlay}
|
||||
onTrackPlayNext={handleAlbumTrackPlayNext}
|
||||
onTrackPlayLater={handleAlbumTrackPlayLater}
|
||||
@@ -2563,9 +2655,36 @@
|
||||
onTrackGoToAlbum={handleAlbumClick}
|
||||
onTrackGoToArtist={handleArtistClick}
|
||||
onPlaylistClick={selectPlaylist}
|
||||
onLabelClick={handleLabelClick}
|
||||
onMusicianClick={handleMusicianClick}
|
||||
activeTrackId={currentTrack?.id ?? null}
|
||||
isPlaybackActive={isPlaying}
|
||||
/>
|
||||
{:else if activeView === 'musician' && selectedMusician}
|
||||
<MusicianPageView
|
||||
musician={selectedMusician}
|
||||
onBack={navGoBack}
|
||||
onAlbumClick={handleAlbumClick}
|
||||
onArtistClick={handleArtistClick}
|
||||
/>
|
||||
{:else if activeView === 'label' && selectedLabel}
|
||||
<LabelView
|
||||
labelId={selectedLabel.id}
|
||||
labelName={selectedLabel.name}
|
||||
onBack={navGoBack}
|
||||
onAlbumClick={handleAlbumClick}
|
||||
onAlbumPlay={playAlbumById}
|
||||
onAlbumPlayNext={queueAlbumNextById}
|
||||
onAlbumPlayLater={queueAlbumLaterById}
|
||||
onAddAlbumToPlaylist={addAlbumToPlaylistById}
|
||||
onAlbumShareQobuz={shareAlbumQobuzLinkById}
|
||||
onAlbumShareSonglink={shareAlbumSonglinkById}
|
||||
onAlbumDownload={downloadAlbumById}
|
||||
onOpenAlbumFolder={openAlbumFolderById}
|
||||
onReDownloadAlbum={reDownloadAlbumById}
|
||||
checkAlbumFullyDownloaded={checkAlbumFullyDownloaded}
|
||||
{downloadStateVersion}
|
||||
/>
|
||||
{:else if activeView === 'library' || activeView === 'library-album'}
|
||||
<LocalLibraryView
|
||||
onTrackPlay={handleLocalTrackPlay}
|
||||
@@ -2887,6 +3006,8 @@
|
||||
trackInfoTrackId = null;
|
||||
}}
|
||||
onArtistClick={handleArtistClick}
|
||||
onLabelClick={handleLabelClick}
|
||||
onMusicianClick={handleMusicianClick}
|
||||
/>
|
||||
|
||||
<!-- Album Credits Modal -->
|
||||
@@ -2906,8 +3027,19 @@
|
||||
}
|
||||
}
|
||||
}}
|
||||
onLabelClick={handleLabelClick}
|
||||
onMusicianClick={handleMusicianClick}
|
||||
/>
|
||||
|
||||
<!-- Musician Modal (for confidence level 0-1) -->
|
||||
{#if musicianModalData}
|
||||
<MusicianModal
|
||||
musician={musicianModalData}
|
||||
onClose={closeMusicianModal}
|
||||
onNavigateToArtist={handleArtistClick}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Cast Picker -->
|
||||
<CastPicker
|
||||
isOpen={isCastPickerOpen}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" ?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" 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:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs id="defs2797"/>
|
||||
<g id="_24-Connect" style="display:inline" transform="translate(-3072,-4096)">
|
||||
<g id="g2211" transform="translate(3339.32,4267.43)">
|
||||
<path d="m 0,169.14 c -44.818,0 -81.28,-36.462 -81.28,-81.28 0,-44.818 36.462,-81.28 81.28,-81.28 44.817,0 81.279,36.462 81.279,81.28 0,44.818 -36.462,81.28 -81.279,81.28 m -149.2,141.43 c -24.587,0 -44.59,-20.003 -44.59,-44.59 0,-24.587 20.003,-44.59 44.59,-44.59 24.587,0 44.59,20.003 44.59,44.59 0,24.587 -20.003,44.59 -44.59,44.59 m -83.91,-398.5 c 0,-29.5 24,-53.5 53.5,-53.5 29.494,0 53.49,24 53.49,53.5 0,29.494 -23.996,53.49 -53.49,53.49 -29.5,0 -53.5,-23.996 -53.5,-53.49 m 412.099,37 c 17.364,0 31.49,14.126 31.49,31.49 0,17.369 -14.126,31.5 -31.49,31.5 -17.369,0 -31.5,-14.131 -31.5,-31.5 0,-17.364 14.131,-31.49 31.5,-31.49 m -44.46,73.915 c 11.207,11.739 26.989,19.075 44.46,19.075 33.907,0 61.49,-27.589 61.49,-61.5 0,-33.906 -27.583,-61.49 -61.49,-61.49 -33.911,0 -61.5,27.584 -61.5,61.49 0,5.628 0.776,11.076 2.198,16.259 L 87.091,18.672 C 66.687,-6.959 35.231,-23.42 0,-23.42 c -26.243,0 -50.388,9.137 -69.436,24.388 l -44.024,-38.046 c 10.859,-14.093 17.34,-31.725 17.34,-50.852 0,-46.042 -37.453,-83.5 -83.49,-83.5 -46.042,0 -83.5,37.458 -83.5,83.5 0,46.036 37.458,83.49 83.5,83.49 16.012,0 30.98,-4.54 43.702,-12.388 l 45.751,39.538 c -13.281,18.326 -21.123,40.838 -21.123,65.15 0,27.971 10.377,53.562 27.478,73.133 l -30.6,39.04 c -10.397,-5.508 -22.236,-8.643 -34.798,-8.643 -41.129,0 -74.59,33.461 -74.59,74.59 0,41.129 33.461,74.59 74.59,74.59 41.129,0 74.59,-33.461 74.59,-74.59 0,-17.745 -6.24,-34.055 -16.625,-46.873 l 30.054,-38.343 c 17.567,11.607 38.596,18.376 61.181,18.376 61.359,0 111.279,-49.92 111.279,-111.28 0,-15.382 -3.138,-30.045 -8.806,-43.383 z" id="path2209" style="fill-rule:nonzero"/>
|
||||
</g>
|
||||
<g id="g2215" transform="translate(3521.08,4502.59)">
|
||||
<path d="M 0,57.45 C -15.842,57.45 -28.73,44.562 -28.73,28.72 -28.73,12.884 -15.842,0 0,0 15.836,0 28.72,12.884 28.72,28.72 28.72,44.562 15.836,57.45 0,57.45 M 0,-30 c -32.384,0 -58.73,26.342 -58.73,58.72 0,32.384 26.346,58.73 58.73,58.73 32.378,0 58.72,-26.346 58.72,-58.73 C 58.72,-3.658 32.378,-30 0,-30" id="path2213" style="fill-rule:nonzero"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,2 @@
|
||||
<?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 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20.5 9a3.49 3.49 0 0 0-3.45 3h-1.1a2.49 2.49 0 0 0-4.396-1.052L8.878 9.731l3.143-4.225a2.458 2.458 0 0 0 2.98-.019L17.339 8H16v1h3V6h-1v1.243l-2.336-2.512A2.473 2.473 0 0 0 16 3.5a2.5 2.5 0 0 0-5 0 2.474 2.474 0 0 0 .343 1.243L7.947 9.308 4.955 7.947a2.404 2.404 0 0 0-.161-1.438l3.704-1.385-.44 1.371.942.333L10 4 7.172 3l-.334.943 1.01.357-3.659 1.368a2.498 2.498 0 1 0-.682 4.117l2.085 2.688-2.053 2.76a2.5 2.5 0 1 0 .87 3.864l3.484 1.587-1.055.373.334.943L10 21l-1-2.828-.943.333.435 1.354-3.608-1.645A2.471 2.471 0 0 0 5 17.5a2.5 2.5 0 0 0-.058-.527l3.053-1.405 3.476 4.48a2.498 2.498 0 1 0 4.113.075L18 17.707V19h1v-3h-3v1h1.293l-2.416 2.416a2.466 2.466 0 0 0-2.667-.047l-3.283-4.23 2.554-1.176A2.494 2.494 0 0 0 15.95 13h1.1a3.493 3.493 0 1 0 3.45-4zm-7-7A1.5 1.5 0 1 1 12 3.5 1.502 1.502 0 0 1 13.5 2zm0 18a1.5 1.5 0 1 1-1.5 1.5 1.502 1.502 0 0 1 1.5-1.5zM1 7.5a1.5 1.5 0 1 1 2.457 1.145l-.144.112A1.496 1.496 0 0 1 1 7.5zm3.32 1.703a2.507 2.507 0 0 0 .264-.326l2.752 1.251-1.124 1.512zM2.5 19A1.5 1.5 0 1 1 4 17.5 1.502 1.502 0 0 1 2.5 19zm2.037-2.941a2.518 2.518 0 0 0-.193-.234l1.885-2.532 1.136 1.464zm3.76-1.731L6.849 12.46l1.42-1.908L11.1 11.84a2.29 2.29 0 0 0-.033 1.213zM13.5 14a1.5 1.5 0 1 1 1.5-1.5 1.502 1.502 0 0 1-1.5 1.5zm7 1a2.5 2.5 0 1 1 2.5-2.5 2.502 2.502 0 0 1-2.5 2.5zm1.5-2.5a1.5 1.5 0 1 1-1.5-1.5 1.502 1.502 0 0 1 1.5 1.5z"/><path fill="none" d="M0 0h24v24H0z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
Reference in new issue
Block a user