diff --git a/crates/Cargo.lock b/crates/Cargo.lock index a5250c3d9..0b1368d35 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -5421,6 +5421,21 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "qbz-playlist-import" +version = "0.1.0" +dependencies = [ + "futures-util", + "log", + "qbz-models", + "qbz-qobuz", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "qbz-plex" version = "0.1.0" @@ -5513,6 +5528,7 @@ dependencies = [ "qbz-models", "qbz-offline-cache", "qbz-player", + "qbz-playlist-import", "qbz-plex", "qbz-qobuz", "qconnect-app", diff --git a/crates/Cargo.toml b/crates/Cargo.toml index 790a58594..2b4c5e0ea 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -25,6 +25,7 @@ members = [ "qbz-credentials", "qbz-media-controls", "qbz-mixtape", + "qbz-playlist-import", "qbz-lyrics", ] diff --git a/crates/qbz-core/src/core.rs b/crates/qbz-core/src/core.rs index 3de9e22d2..5517469f1 100644 --- a/crates/qbz-core/src/core.rs +++ b/crates/qbz-core/src/core.rs @@ -10,7 +10,8 @@ use qbz_models::{ Album, Artist, ArtistAlbums, CoreEvent, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, DiscoverResponse, FrontendAdapter, GenreInfo, LabelExploreResponse, LabelGetListResponse, LabelListPage, LabelPageData, LabelStoryResponse, PageArtistResponse, - MostPopularItem, Playlist, PlaylistTag, Quality, QueueState, QueueTrack, ReleasesGridResponse, + MostPopularItem, Playlist, PlaylistDuplicateResult, PlaylistTag, Quality, QueueState, + QueueTrack, ReleasesGridResponse, RepeatMode, SearchAllResults, SearchResultsPage, StreamUrl, Track, TracksContainer, UserSession, }; @@ -1015,6 +1016,27 @@ impl QbzCore { .map_err(CoreError::Api) } + /// Check how many of `track_ids` are already in the Qobuz playlist + /// `playlist_id`. Mirrors Tauri's `v2_check_playlist_duplicates` + /// (commands_v2/playlists.rs): fetch the playlist's existing track ids and + /// set-intersect with the input. This is Qobuz-tracks-into-Qobuz-playlist + /// only — callers gate out local / Plex / local-playlist targets before + /// calling (those never duplicate-check, mirroring the Tauri flow). + pub async fn check_playlist_duplicates( + &self, + playlist_id: u64, + track_ids: &[u64], + ) -> Result { + let client = self.client.read().await; + let client = client.as_ref().ok_or(CoreError::NotInitialized)?; + + let playlist = client + .get_playlist_track_ids(playlist_id) + .await + .map_err(CoreError::Api)?; + Ok(compute_playlist_duplicates(&playlist.track_ids, track_ids)) + } + /// Remove tracks from playlist pub async fn remove_tracks_from_playlist( &self, @@ -2336,6 +2358,28 @@ fn shuffle_with_seed(items: &mut Vec, seed_mbid: &str, secondary_tag: Opti } } +/// Pure set-intersection behind [`QbzCore::check_playlist_duplicates`] — split +/// out so the duplicate logic is unit-testable without a live Qobuz client. +/// `existing` = the playlist's current track ids; `track_ids` = the ids the +/// user wants to add. Returns the Tauri-shaped result (total checked, how many +/// are already present, and the set of those duplicate ids). +pub(crate) fn compute_playlist_duplicates( + existing: &[u64], + track_ids: &[u64], +) -> PlaylistDuplicateResult { + let existing_set: std::collections::HashSet = existing.iter().copied().collect(); + let duplicate_track_ids: std::collections::HashSet = track_ids + .iter() + .copied() + .filter(|id| existing_set.contains(id)) + .collect(); + PlaylistDuplicateResult { + total_tracks: track_ids.len(), + duplicate_count: duplicate_track_ids.len(), + duplicate_track_ids, + } +} + #[cfg(test)] mod tests { use super::*; @@ -2361,4 +2405,33 @@ mod tests { assert_eq!(out.artists.total, 1); assert!(out.most_popular.is_none()); } + + #[test] + fn compute_playlist_duplicates_intersects_input_with_existing() { + // Existing playlist has 10, 20, 30. Adding 20, 30, 40, 50: + // 20 and 30 are duplicates; 40 and 50 are new. + let existing = [10u64, 20, 30]; + let to_add = [20u64, 30, 40, 50]; + let r = compute_playlist_duplicates(&existing, &to_add); + assert_eq!(r.total_tracks, 4); + assert_eq!(r.duplicate_count, 2); + assert!(r.duplicate_track_ids.contains(&20)); + assert!(r.duplicate_track_ids.contains(&30)); + assert!(!r.duplicate_track_ids.contains(&40)); + } + + #[test] + fn compute_playlist_duplicates_none_when_disjoint() { + let r = compute_playlist_duplicates(&[1u64, 2, 3], &[4u64, 5]); + assert_eq!(r.total_tracks, 2); + assert_eq!(r.duplicate_count, 0); + assert!(r.duplicate_track_ids.is_empty()); + } + + #[test] + fn compute_playlist_duplicates_empty_input() { + let r = compute_playlist_duplicates(&[1u64, 2, 3], &[]); + assert_eq!(r.total_tracks, 0); + assert_eq!(r.duplicate_count, 0); + } } diff --git a/crates/qbz-playlist-import/Cargo.toml b/crates/qbz-playlist-import/Cargo.toml new file mode 100644 index 000000000..1cb5ec331 --- /dev/null +++ b/crates/qbz-playlist-import/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "qbz-playlist-import" +version = "0.1.0" +edition = "2021" +description = "Public-playlist import for QBZ — provider scrapers, Qobuz matcher, playlist creation (headless, frontend-agnostic)" +authors = ["blitzkriegfc"] + +[dependencies] +# Shared QBZ crates (the real models + client, not src-tauri shims) +qbz-models = { path = "../qbz-models" } +qbz-qobuz = { path = "../qbz-qobuz" } + +# HTTP (one shared client for all providers — see src/http.rs) +reqwest = { workspace = true } + +# Serialization +serde = { workspace = true } +serde_json = { workspace = true } + +# Error handling +thiserror = { workspace = true } + +# Logging +log = { workspace = true } + +# Async runtime + concurrency +tokio = { workspace = true } +futures-util = { workspace = true } diff --git a/crates/qbz-playlist-import/src/errors.rs b/crates/qbz-playlist-import/src/errors.rs new file mode 100644 index 000000000..97c936ec8 --- /dev/null +++ b/crates/qbz-playlist-import/src/errors.rs @@ -0,0 +1,20 @@ +//! Errors for playlist import + +use thiserror::Error; + +// The Tauri original also had a `MissingCredentials` variant — dead code +// (zero call sites since the providers moved to the credential proxy); +// dropped in the extraction along with `ProviderCredentials`. +#[derive(Debug, Error)] +pub enum PlaylistImportError { + #[error("Invalid playlist URL: {0}")] + InvalidUrl(String), + #[error("Provider not supported: {0}")] + UnsupportedProvider(String), + #[error("HTTP error: {0}")] + Http(String), + #[error("Parse error: {0}")] + Parse(String), + #[error("Qobuz error: {0}")] + Qobuz(String), +} diff --git a/crates/qbz-playlist-import/src/http.rs b/crates/qbz-playlist-import/src/http.rs new file mode 100644 index 000000000..f3d2bc217 --- /dev/null +++ b/crates/qbz-playlist-import/src/http.rs @@ -0,0 +1,27 @@ +//! Shared HTTP client for all providers. +//! +//! The Tauri original built a new connection pool per request (`reqwest::get` +//! in four call sites, `Client::new()` per Tidal fetch, plus a builder client +//! per token fetch) — each one its own fd pool, which exhausted file +//! descriptors (EMFILE) on large imports. One `OnceLock` client fixes that; +//! async flavor of the existing blocking precedent in +//! `qbz-media-controls/src/notify.rs`. +//! +//! UA policy (behavior-faithful): NO default User-Agent on the client — +//! the scrapers go out exactly as before; only the Tidal proxy token request +//! sets [`USER_AGENT`] per-request, as the original did. + +/// User-Agent sent to the QBZ credential proxy (Tidal token fetch only). +pub(crate) const USER_AGENT: &str = "QBZ/1.0.0"; + +const CONNECT_TIMEOUT_SECS: u64 = 10; + +pub(crate) fn http() -> &'static reqwest::Client { + static CLIENT: std::sync::OnceLock = std::sync::OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS)) + .build() + .expect("static reqwest client") + }) +} diff --git a/crates/qbz-playlist-import/src/importer.rs b/crates/qbz-playlist-import/src/importer.rs new file mode 100644 index 000000000..7de9d02d8 --- /dev/null +++ b/crates/qbz-playlist-import/src/importer.rs @@ -0,0 +1,136 @@ +//! Orchestrates playlist import + +use std::sync::Arc; + +use qbz_qobuz::QobuzClient; + +use crate::errors::PlaylistImportError; +use crate::match_qobuz::match_tracks; +use crate::models::{ImportPlaylist, ImportProgress, ImportSummary}; +use crate::providers::{detect_provider, fetch_playlist}; +use crate::sink::{ImportEvent, ImportPhase, ImportProgressSink}; + +const ADD_CHUNK_SIZE: usize = 50; +const QOBUZ_PLAYLIST_TRACK_LIMIT: usize = 2000; + +pub async fn preview_public_playlist(url: &str) -> Result { + let provider = detect_provider(url)?; + fetch_playlist(provider).await +} + +pub async fn import_public_playlist( + url: &str, + client: &QobuzClient, + name_override: Option<&str>, + is_public: bool, + progress: Arc, +) -> Result { + let playlist = preview_public_playlist(url).await?; + + // Phase: matching + progress.emit(ImportEvent::Phase(ImportPhase::Matching)); + let matches = match_tracks(client, &playlist.tracks, Arc::clone(&progress)).await?; + + let mut matched_track_ids = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for entry in &matches { + if let Some(id) = entry.qobuz_track_id { + if seen.insert(id) { + matched_track_ids.push(id); + } + } + } + + let matched_count = matched_track_ids.len() as u32; + let total_tracks = playlist.tracks.len() as u32; + let skipped_tracks = total_tracks.saturating_sub(matched_count); + + let mut qobuz_playlist_ids = Vec::new(); + + if !matched_track_ids.is_empty() { + let base_name = name_override.unwrap_or(&playlist.name); + let description = playlist + .description + .clone() + .or_else(|| Some(format!("Imported from {}", playlist.provider.as_str()))); + + // Split into parts if more than QOBUZ_PLAYLIST_TRACK_LIMIT tracks + let parts: Vec<&[u64]> = matched_track_ids + .chunks(QOBUZ_PLAYLIST_TRACK_LIMIT) + .collect(); + let total_parts = parts.len(); + + for (part_idx, part_tracks) in parts.iter().enumerate() { + // Phase: creating (per part) + progress.emit(ImportEvent::Phase(ImportPhase::Creating)); + + let playlist_name = if total_parts == 1 { + base_name.to_string() + } else { + format!("{} (Part {})", base_name, part_idx + 1) + }; + + let part_desc = if total_parts == 1 { + description.clone() + } else { + Some(format!( + "Part {} of {} — {}", + part_idx + 1, + total_parts, + description.as_deref().unwrap_or("") + )) + }; + + let created = client + .create_playlist(&playlist_name, part_desc.as_deref(), is_public) + .await + .map_err(|e| PlaylistImportError::Qobuz(e.to_string()))?; + + qobuz_playlist_ids.push(created.id); + + // Phase: adding + progress.emit(ImportEvent::Phase(ImportPhase::Adding)); + + let chunks: Vec<&[u64]> = part_tracks.chunks(ADD_CHUNK_SIZE).collect(); + let total_chunks = chunks.len() as u32; + + for (i, chunk) in chunks.iter().enumerate() { + client + .add_tracks_to_playlist(created.id, chunk) + .await + .map_err(|e| PlaylistImportError::Qobuz(e.to_string()))?; + + progress.emit(ImportEvent::Progress(ImportProgress { + phase: "adding".to_string(), + current: (i as u32) + 1, + total: total_chunks, + matched_so_far: matched_count, + current_track: if total_parts > 1 { + Some(format!("Part {}/{}", part_idx + 1, total_parts)) + } else { + None + }, + })); + } + } + } + + let parts_created = qobuz_playlist_ids.len() as u32; + + Ok(ImportSummary { + provider: playlist.provider, + // Deliberate fix vs the Tauri original (owner decision): the summary + // reports the name the playlist was actually created under — the + // rename when one was given — not the original source name. + playlist_name: match name_override { + Some(name) => name.to_string(), + None => playlist.name, + }, + total_tracks, + matched_tracks: matched_count, + skipped_tracks, + qobuz_playlist_ids, + parts_created, + matches, + }) +} diff --git a/crates/qbz-playlist-import/src/lib.rs b/crates/qbz-playlist-import/src/lib.rs new file mode 100644 index 000000000..a8313a7a9 --- /dev/null +++ b/crates/qbz-playlist-import/src/lib.rs @@ -0,0 +1,158 @@ +//! Public-playlist import for QBZ — provider scrapers, Qobuz matcher, +//! playlist creation (headless, frontend-agnostic). +//! +//! Extracted verbatim from `src-tauri/src/playlist_import/*` and re-typed +//! against the shared crates (`qbz-models`, `qbz-qobuz`), so it runs headless +//! with no Tauri state and no `#[tauri::command]` wrappers (ADR-005: no +//! legacy wrappers; ADR-006: frontend-agnostic core). Progress reaches the +//! frontend through the [`sink::ImportProgressSink`] trait instead of +//! `AppHandle::emit`. +//! +//! Known provider limitations (behavior-faithful copies of the Tauri code; +//! follow-up TODOs, not bugs introduced by the extraction): +//! - Spotify: embed scraping only (API access gone since 2026-03-06) — caps +//! at ~50 tracks and provides no ISRC or album data. +//! TODO: pagination/ISRC if a richer public source appears. +//! - Deezer: single public API call, no pagination — truncates around 400 +//! tracks. TODO: paginate `tracks.data`. +//! - Apple Music: scrapes `serialized-server-data` from the playlist page — +//! the most fragile parser of the four. +//! - Tidal: fetches a fresh proxy token per playlist fetch (no caching/expiry +//! handling). TODO: cache the token until expiry. +//! - Scrapers send no browser User-Agent (reqwest default) — TODO if any +//! provider starts gating on UA. + +pub mod errors; +pub mod importer; +pub mod match_qobuz; +pub mod models; +pub mod providers; +pub mod sink; + +mod http; + +pub use errors::PlaylistImportError; +pub use importer::{import_public_playlist, preview_public_playlist}; +pub use models::{ + ImportPlaylist, ImportProgress, ImportProvider, ImportSummary, ImportTrack, TrackMatch, +}; +pub use providers::{detect_music_resource, MusicProvider, MusicResource}; +pub use sink::{ImportEvent, ImportPhase, ImportProgressSink}; + +/// Cloudflare Workers proxy that holds the third-party API credentials. +/// Hoisted from the Tidal provider so the future link-resolver port shares +/// the one constant instead of duplicating it. +pub const QBZ_PROXY_BASE: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev"; + +/// Provider key for the UI gate ("spotify" | "apple" | "tidal" | "deezer"). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderKey { + Spotify, + Apple, + Tidal, + Deezer, +} + +impl ProviderKey { + pub fn as_str(&self) -> &'static str { + match self { + ProviderKey::Spotify => "spotify", + ProviderKey::Apple => "apple", + ProviderKey::Tidal => "tidal", + ProviderKey::Deezer => "deezer", + } + } +} + +/// UI-gate provider detection — the exact substring rules of the Svelte +/// `detectProvider` (looser than [`providers::detect_provider`], which stays +/// the authoritative backend validation and may reject what this gate +/// passed). Kept here so the UI enable/disable logic and the backend share +/// one source of truth. +pub fn detect_provider_key(url: &str) -> Option { + let url = url.trim(); + + if url.starts_with("spotify:playlist:") + || url.contains("open.spotify.com/playlist/") + || url.contains("open.spotify.com/embed/playlist/") + { + return Some(ProviderKey::Spotify); + } + if url.contains("music.apple.com/") && url.contains("/playlist/") { + return Some(ProviderKey::Apple); + } + if url.contains("tidal.com/") && url.contains("/playlist/") { + return Some(ProviderKey::Tidal); + } + if url.contains("deezer.com/") && url.contains("/playlist/") { + return Some(ProviderKey::Deezer); + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_provider_key_table() { + let cases: &[(&str, Option)] = &[ + // Spotify: URI, URL, embed + ( + "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M", + Some(ProviderKey::Spotify), + ), + ( + "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", + Some(ProviderKey::Spotify), + ), + ( + "https://open.spotify.com/embed/playlist/37i9dQZF1DXcBWIGoYBM5M", + Some(ProviderKey::Spotify), + ), + // Apple: needs both music.apple.com/ AND /playlist/ + ( + "https://music.apple.com/us/playlist/top-100/pl.123", + Some(ProviderKey::Apple), + ), + ("https://music.apple.com/us/album/x/123", None), + // Tidal + ( + "https://tidal.com/browse/playlist/abc-def", + Some(ProviderKey::Tidal), + ), + ("https://tidal.com/browse/album/123", None), + // Deezer + ( + "https://www.deezer.com/en/playlist/1234567", + Some(ProviderKey::Deezer), + ), + ("https://www.deezer.com/en/album/1234567", None), + // Rejects + ("https://open.spotify.com/track/abc", None), + ("https://example.com/playlist/1", None), + ("", None), + ]; + + for (url, expected) in cases { + assert_eq!(detect_provider_key(url), *expected, "url: {}", url); + } + } + + #[test] + fn detect_provider_key_trims_whitespace() { + assert_eq!( + detect_provider_key(" spotify:playlist:abc "), + Some(ProviderKey::Spotify) + ); + } + + #[test] + fn provider_key_as_str() { + assert_eq!(ProviderKey::Spotify.as_str(), "spotify"); + assert_eq!(ProviderKey::Apple.as_str(), "apple"); + assert_eq!(ProviderKey::Tidal.as_str(), "tidal"); + assert_eq!(ProviderKey::Deezer.as_str(), "deezer"); + } +} diff --git a/crates/qbz-playlist-import/src/match_qobuz.rs b/crates/qbz-playlist-import/src/match_qobuz.rs new file mode 100644 index 000000000..79d668eb6 --- /dev/null +++ b/crates/qbz-playlist-import/src/match_qobuz.rs @@ -0,0 +1,552 @@ +//! Match imported tracks to Qobuz catalog + +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; + +use futures_util::stream::{self, StreamExt}; +use qbz_models::Track; +use qbz_qobuz::QobuzClient; + +use crate::errors::PlaylistImportError; +use crate::models::{ImportProgress, ImportTrack, TrackMatch}; +use crate::sink::{ImportEvent, ImportProgressSink}; + +const SEARCH_LIMIT: u32 = 20; +const TITLE_WEIGHT: f32 = 0.6; +const ARTIST_WEIGHT: f32 = 0.3; +const ALBUM_WEIGHT: f32 = 0.1; +const MIN_SCORE: f32 = 0.65; +const CONCURRENCY: usize = 8; + +pub async fn match_tracks( + client: &QobuzClient, + tracks: &[ImportTrack], + progress: Arc, +) -> Result, PlaylistImportError> { + let total = tracks.len() as u32; + let matched_counter = Arc::new(AtomicU32::new(0)); + let completed_counter = Arc::new(AtomicU32::new(0)); + + // Pre-allocate results vector with None slots + let results: Arc>>> = + Arc::new(tokio::sync::Mutex::new(vec![None; tracks.len()])); + + let owned_tracks: Vec<(usize, ImportTrack)> = tracks + .iter() + .enumerate() + .map(|(i, tr)| (i, tr.clone())) + .collect(); + + stream::iter(owned_tracks) + .map(|(idx, track)| { + let client = client.clone(); + let progress = Arc::clone(&progress); + let matched_counter = Arc::clone(&matched_counter); + let completed_counter = Arc::clone(&completed_counter); + let results = Arc::clone(&results); + + async move { + let query = format!("{} {}", track.artist, track.title); + let search_result = client.search_tracks(&query, SEARCH_LIMIT, 0, None).await; + + let match_entry = match search_result { + Ok(search) => { + let (best, score) = select_best_match(&track, &search.items); + match best { + Some(candidate) if score >= MIN_SCORE => { + matched_counter.fetch_add(1, Ordering::Relaxed); + TrackMatch { + source: track.clone(), + qobuz_track_id: Some(candidate.id), + qobuz_title: Some(candidate.title.clone()), + qobuz_artist: candidate + .performer + .as_ref() + .map(|a| a.name.clone()), + score, + } + } + _ => TrackMatch { + source: track.clone(), + qobuz_track_id: None, + qobuz_title: None, + qobuz_artist: None, + score, + }, + } + } + Err(e) => { + log::warn!( + "Search failed for '{}' - '{}': {}", + track.artist, + track.title, + e + ); + TrackMatch { + source: track.clone(), + qobuz_track_id: None, + qobuz_title: None, + qobuz_artist: None, + score: 0.0, + } + } + }; + + // Store result at correct index + { + let mut res = results.lock().await; + res[idx] = Some(match_entry); + } + + let current = completed_counter.fetch_add(1, Ordering::Relaxed) + 1; + let matched = matched_counter.load(Ordering::Relaxed); + + let current_track = Some(format!("{} - {}", track.artist, track.title)); + + progress.emit(ImportEvent::Progress(ImportProgress { + phase: "matching".to_string(), + current, + total, + matched_so_far: matched, + current_track, + })); + } + }) + .buffer_unordered(CONCURRENCY) + .collect::>() + .await; + + // Extract results in order + let locked = results.lock().await; + let ordered: Vec = locked + .iter() + .map(|slot| slot.clone().expect("All slots should be filled")) + .collect(); + + Ok(ordered) +} + +fn select_best_match<'a>(track: &ImportTrack, candidates: &'a [Track]) -> (Option<&'a Track>, f32) { + let mut best: Option<&Track> = None; + let mut best_score = 0.0f32; + let mut best_quality = 0.0f32; + + for candidate in candidates { + if !candidate.streamable { + continue; + } + + let score = score_candidate(track, candidate); + let quality = quality_score(candidate); + + if score > best_score + 0.0001 { + best = Some(candidate); + best_score = score; + best_quality = quality; + } else if (score - best_score).abs() < 0.01 && quality > best_quality { + best = Some(candidate); + best_quality = quality; + } + } + + (best, best_score) +} + +fn score_candidate(track: &ImportTrack, candidate: &Track) -> f32 { + if let (Some(isrc), Some(candidate_isrc)) = (&track.isrc, &candidate.isrc) { + if isrc.eq_ignore_ascii_case(candidate_isrc) { + return 1.0; + } + } + + let title_score = similarity(&track.title, &candidate.title); + let artist_score = similarity( + &track.artist, + candidate + .performer + .as_ref() + .map(|a| a.name.as_str()) + .unwrap_or(""), + ); + let album_score = track + .album + .as_ref() + .map(|album| { + candidate + .album + .as_ref() + .map(|a| similarity(album, &a.title)) + .unwrap_or(0.0) + }) + .unwrap_or(0.0); + + let mut score = + title_score * TITLE_WEIGHT + artist_score * ARTIST_WEIGHT + album_score * ALBUM_WEIGHT; + + if let (Some(import_duration), Some(candidate_duration)) = ( + track.duration_ms, + Some((candidate.duration as u64).saturating_mul(1000)), + ) { + let diff = if import_duration > candidate_duration { + import_duration - candidate_duration + } else { + candidate_duration - import_duration + }; + + if diff <= 3000 { + score += 0.05; + } else if diff <= 5000 { + score += 0.02; + } + } + + score +} + +fn similarity(a: &str, b: &str) -> f32 { + let na = normalize(a); + let nb = normalize(b); + + if na.is_empty() || nb.is_empty() { + return 0.0; + } + + if na == nb { + return 1.0; + } + + if na.contains(&nb) || nb.contains(&na) { + return 0.85; + } + + token_overlap(&na, &nb) +} + +fn normalize(input: &str) -> String { + let stripped = remove_bracketed(input); + let mut cleaned = String::new(); + + for ch in stripped.chars() { + if ch.is_ascii_alphanumeric() || ch.is_whitespace() { + cleaned.push(ch.to_ascii_lowercase()); + } else { + cleaned.push(' '); + } + } + + let stop_words = [ + "remaster", + "remastered", + "deluxe", + "edition", + "live", + "feat", + "featuring", + "version", + "mix", + "mono", + "stereo", + "edit", + ]; + + cleaned + .split_whitespace() + .filter(|token| !stop_words.contains(token)) + .collect::>() + .join(" ") +} + +fn remove_bracketed(input: &str) -> String { + let mut out = String::new(); + let mut depth = 0u32; + + for ch in input.chars() { + match ch { + '(' | '[' => { + depth += 1; + } + ')' | ']' => { + if depth > 0 { + depth -= 1; + } + } + _ => { + if depth == 0 { + out.push(ch); + } + } + } + } + + out +} + +fn token_overlap(a: &str, b: &str) -> f32 { + let a_tokens: Vec<&str> = a.split_whitespace().collect(); + let b_tokens: Vec<&str> = b.split_whitespace().collect(); + + if a_tokens.is_empty() || b_tokens.is_empty() { + return 0.0; + } + + let mut matches = 0u32; + for token in &a_tokens { + if b_tokens.contains(token) { + matches += 1; + } + } + + matches as f32 / a_tokens.len().max(b_tokens.len()) as f32 +} + +fn quality_score(track: &Track) -> f32 { + let bit_depth = track.maximum_bit_depth.unwrap_or(0) as f32; + let sample_rate = track.maximum_sampling_rate.unwrap_or(0.0) as f32; + bit_depth * 100000.0 + sample_rate +} + +#[cfg(test)] +mod tests { + use super::*; + use qbz_models::{AlbumSummary, Artist, ImageSet}; + + fn import_track(title: &str, artist: &str) -> ImportTrack { + ImportTrack { + title: title.to_string(), + artist: artist.to_string(), + album: None, + duration_ms: None, + isrc: None, + provider_id: None, + provider_url: None, + } + } + + fn qobuz_track(id: u64, title: &str, artist: &str) -> Track { + Track { + id, + title: title.to_string(), + version: None, + isrc: None, + duration: 0, + track_number: 0, + media_number: None, + performer: Some(Artist { + name: artist.to_string(), + ..Artist::default() + }), + album: None, + hires: false, + hires_streamable: false, + maximum_sampling_rate: None, + maximum_bit_depth: None, + streamable: true, + parental_warning: false, + playlist_track_id: None, + performers: None, + composer: None, + copyright: None, + } + } + + fn album_summary(title: &str) -> AlbumSummary { + AlbumSummary { + id: String::new(), + title: title.to_string(), + image: ImageSet::default(), + label: None, + genre: None, + } + } + + // ── normalize / remove_bracketed / token_overlap ── + + #[test] + fn normalize_strips_brackets_and_stop_words() { + assert_eq!(normalize("Song Title (Remastered 2011)"), "song title"); + assert_eq!(normalize("Song Title [Deluxe Edition]"), "song title"); + assert_eq!(normalize("Hey Jude - Remastered"), "hey jude"); + } + + #[test] + fn normalize_handles_nested_brackets() { + assert_eq!(normalize("Title (Live [2020] Version)"), "title"); + } + + #[test] + fn normalize_degrades_non_ascii_to_spaces() { + // Accented/CJK chars are not ASCII-alphanumeric → replaced by spaces. + // Locks the (lossy) accented-title behavior of the Tauri original. + assert_eq!(normalize("Café Tacvba"), "caf tacvba"); + assert_eq!(normalize("너의 의미"), ""); + } + + #[test] + fn normalize_lowercases_and_collapses_punctuation() { + assert_eq!(normalize("AC/DC - T.N.T."), "ac dc t n t"); + } + + #[test] + fn remove_bracketed_basic_and_nested() { + assert_eq!(remove_bracketed("a (b) c"), "a c"); + assert_eq!(remove_bracketed("a (b [c] d) e"), "a e"); + assert_eq!(remove_bracketed("no brackets"), "no brackets"); + // Unbalanced closers are ignored at depth 0 + assert_eq!(remove_bracketed(") leading"), " leading"); + } + + #[test] + fn token_overlap_ratio_uses_longer_side() { + assert_eq!(token_overlap("a b", "a b"), 1.0); + assert_eq!(token_overlap("a b", "a b c d"), 0.5); + assert_eq!(token_overlap("x", "y"), 0.0); + assert_eq!(token_overlap("", "a"), 0.0); + } + + // ── similarity ── + + #[test] + fn similarity_exact_after_normalization_is_one() { + assert_eq!(similarity("Hey Jude (Remastered)", "hey jude"), 1.0); + } + + #[test] + fn similarity_substring_is_085() { + assert_eq!(similarity("hey jude", "hey jude na na"), 0.85); + } + + #[test] + fn similarity_falls_back_to_token_overlap() { + // "hey there" vs "jude there": 1 shared token / max(2, 2) = 0.5 + assert_eq!(similarity("hey there", "jude there"), 0.5); + } + + #[test] + fn similarity_empty_is_zero() { + assert_eq!(similarity("", "anything"), 0.0); + assert_eq!(similarity("anything", ""), 0.0); + } + + // ── score_candidate ── + + #[test] + fn score_candidate_isrc_short_circuit_is_case_insensitive() { + let mut source = import_track("Completely Different", "Nobody"); + source.isrc = Some("uskO11600123".to_string()); + let mut candidate = qobuz_track(1, "Other Title", "Other Artist"); + candidate.isrc = Some("USKO11600123".to_string()); + assert_eq!(score_candidate(&source, &candidate), 1.0); + } + + #[test] + fn score_candidate_weights_title_artist_album() { + let mut source = import_track("hey jude", "the beatles"); + source.album = Some("past masters".to_string()); + let mut candidate = qobuz_track(1, "hey jude", "the beatles"); + candidate.album = Some(album_summary("past masters")); + // duration_ms is None on the source → no duration bonus, so the score + // is exactly the 0.6/0.3/0.1 weighted sum (all components 1.0 here). + let score = score_candidate(&source, &candidate); + assert!((score - 1.0).abs() < 1e-6, "got {}", score); + } + + #[test] + fn score_candidate_duration_bonus_tiers() { + let mut source = import_track("hey jude", "the beatles"); + let mut candidate = qobuz_track(1, "hey jude", "the beatles"); + candidate.duration = 200; // 200_000 ms + + // No source duration → no bonus (source-duration-only quirk). + let base = score_candidate(&source, &candidate); + assert!((base - 0.9).abs() < 1e-6, "got {}", base); + + // Within 3s → +0.05 + source.duration_ms = Some(202_000); + let close = score_candidate(&source, &candidate); + assert!((close - 0.95).abs() < 1e-6, "got {}", close); + + // Within 5s → +0.02 + source.duration_ms = Some(204_500); + let near = score_candidate(&source, &candidate); + assert!((near - 0.92).abs() < 1e-6, "got {}", near); + + // Beyond 5s → no bonus + source.duration_ms = Some(210_000); + let far = score_candidate(&source, &candidate); + assert!((far - 0.9).abs() < 1e-6, "got {}", far); + } + + // ── select_best_match ── + + #[test] + fn select_best_match_skips_non_streamable() { + let source = import_track("hey jude", "the beatles"); + let mut perfect = qobuz_track(1, "hey jude", "the beatles"); + perfect.streamable = false; + let weaker = qobuz_track(2, "hey jude na na", "the beatles"); + + let candidates = [perfect, weaker]; + let (best, _score) = select_best_match(&source, &candidates); + assert_eq!(best.map(|t| t.id), Some(2)); + } + + #[test] + fn select_best_match_score_below_min_score_threshold() { + // select_best_match does NOT gate on MIN_SCORE — the CALLER + // (match_tracks) drops sub-threshold matches. Prove a PARTIAL match + // (same artist, different title → ~0.3, well under 0.65) is still + // RETURNED, leaving the rejection to the caller. NOTE: it must be a + // partial, not pure junk — a fully disjoint candidate scores 0.0, + // never beats the 0.0 seed (`score > best_score + 0.0001`), and comes + // back as None, which is a different code path. + let source = import_track("hey jude", "the beatles"); + let partial = qobuz_track(1, "let it be", "the beatles"); + let candidates = [partial]; + let (best, score) = select_best_match(&source, &candidates); + assert!(best.is_some(), "a partial match must still be returned"); + assert!(score > 0.0 && score < MIN_SCORE, "got {}", score); + } + + #[test] + fn select_best_match_hi_res_tiebreak_within_001() { + let source = import_track("hey jude", "the beatles"); + let mut cd = qobuz_track(1, "hey jude", "the beatles"); + cd.maximum_bit_depth = Some(16); + cd.maximum_sampling_rate = Some(44.1); + let mut hires = qobuz_track(2, "hey jude", "the beatles"); + hires.maximum_bit_depth = Some(24); + hires.maximum_sampling_rate = Some(192.0); + + // Equal scores → quality_score decides, regardless of order. + let ordered = [cd.clone(), hires.clone()]; + let (best, _) = select_best_match(&source, &ordered); + assert_eq!(best.map(|t| t.id), Some(2)); + let reversed = [hires, cd]; + let (best, _) = select_best_match(&source, &reversed); + assert_eq!(best.map(|t| t.id), Some(2)); + } + + #[test] + fn select_best_match_empty_candidates() { + let source = import_track("hey jude", "the beatles"); + let (best, score) = select_best_match(&source, &[]); + assert!(best.is_none()); + assert_eq!(score, 0.0); + } + + // ── quality_score ── + + #[test] + fn quality_score_weighs_bit_depth_over_sample_rate() { + let mut a = qobuz_track(1, "x", "y"); + a.maximum_bit_depth = Some(24); + a.maximum_sampling_rate = Some(44.1); + let mut b = qobuz_track(2, "x", "y"); + b.maximum_bit_depth = Some(16); + b.maximum_sampling_rate = Some(192.0); + assert!(quality_score(&a) > quality_score(&b)); + + let none = qobuz_track(3, "x", "y"); + assert_eq!(quality_score(&none), 0.0); + } +} diff --git a/crates/qbz-playlist-import/src/models.rs b/crates/qbz-playlist-import/src/models.rs new file mode 100644 index 000000000..767f81f26 --- /dev/null +++ b/crates/qbz-playlist-import/src/models.rs @@ -0,0 +1,72 @@ +//! Data models for playlist import + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ImportProvider { + Spotify, + AppleMusic, + Tidal, + Deezer, +} + +impl ImportProvider { + pub fn as_str(&self) -> &'static str { + match self { + ImportProvider::Spotify => "spotify", + ImportProvider::AppleMusic => "apple_music", + ImportProvider::Tidal => "tidal", + ImportProvider::Deezer => "deezer", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportTrack { + pub title: String, + pub artist: String, + pub album: Option, + pub duration_ms: Option, + pub isrc: Option, + pub provider_id: Option, + pub provider_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportPlaylist { + pub provider: ImportProvider, + pub provider_id: String, + pub name: String, + pub description: Option, + pub tracks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrackMatch { + pub source: ImportTrack, + pub qobuz_track_id: Option, + pub qobuz_title: Option, + pub qobuz_artist: Option, + pub score: f32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportSummary { + pub provider: ImportProvider, + pub playlist_name: String, + pub total_tracks: u32, + pub matched_tracks: u32, + pub skipped_tracks: u32, + pub qobuz_playlist_ids: Vec, + pub parts_created: u32, + pub matches: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportProgress { + pub phase: String, + pub current: u32, + pub total: u32, + pub matched_so_far: u32, + pub current_track: Option, +} diff --git a/crates/qbz-playlist-import/src/providers/apple.rs b/crates/qbz-playlist-import/src/providers/apple.rs new file mode 100644 index 000000000..3f32900e0 --- /dev/null +++ b/crates/qbz-playlist-import/src/providers/apple.rs @@ -0,0 +1,326 @@ +//! Apple Music playlist import + +use serde_json::Value; + +use crate::errors::PlaylistImportError; +use crate::http::http; +use crate::models::{ImportPlaylist, ImportProvider, ImportTrack}; + +/// Detect if a URL is an Apple Music track, album, or playlist. +/// +/// Apple Music URLs: +/// - Track: `music.apple.com/{storefront}/album/{name}/{id}?i={track_id}` +/// - Album: `music.apple.com/{storefront}/album/{name}/{id}` (no `?i=`) +/// - Playlist: `music.apple.com/{storefront}/playlist/{name}/{pl.xxx}` +/// - Song: `music.apple.com/{storefront}/song/{name}/{id}` +pub fn detect_resource(url: &str) -> Option { + if !url.contains("music.apple.com/") { + return None; + } + + // Playlist + if parse_playlist_id(url).is_some() { + return Some(super::MusicResource::Playlist { + provider: super::MusicProvider::AppleMusic, + }); + } + + // Song page (explicit song URL) + if url.contains("/song/") { + return Some(super::MusicResource::Track { + provider: super::MusicProvider::AppleMusic, + url: url.to_string(), + }); + } + + // Album page — with ?i= parameter means specific track + if url.contains("/album/") { + if url.contains("?i=") || url.contains("&i=") { + return Some(super::MusicResource::Track { + provider: super::MusicProvider::AppleMusic, + url: url.to_string(), + }); + } + return Some(super::MusicResource::Album { + provider: super::MusicProvider::AppleMusic, + url: url.to_string(), + }); + } + + None +} + +pub fn parse_playlist_id(url: &str) -> Option<(String, String)> { + if !url.contains("music.apple.com/") { + return None; + } + + let parts: Vec<&str> = url.split('/').collect(); + if parts.len() < 6 { + return None; + } + + let storefront = parts.get(3)?.to_string(); + let playlist_id = parts.last()?.split('?').next()?.to_string(); + + if playlist_id.starts_with("pl.") || playlist_id.starts_with("pl.u-") { + Some((storefront, playlist_id)) + } else { + None + } +} + +pub async fn fetch_playlist( + storefront: &str, + playlist_id: &str, +) -> Result { + let url = format!( + "https://music.apple.com/{}/playlist/{}", + storefront, playlist_id + ); + let html = http() + .get(&url) + .send() + .await + .map_err(|e| PlaylistImportError::Http(e.to_string()))? + .text() + .await + .map_err(|e| PlaylistImportError::Http(e.to_string()))?; + + let name = + extract_meta(&html, "og:title").unwrap_or_else(|| "Apple Music Playlist".to_string()); + let description = extract_meta(&html, "og:description").filter(|v| !v.is_empty()); + + let json_text = extract_script(&html, "serialized-server-data").ok_or_else(|| { + PlaylistImportError::Parse("Apple Music serialized-server-data not found".to_string()) + })?; + + let data: Value = + serde_json::from_str(&json_text).map_err(|e| PlaylistImportError::Parse(e.to_string()))?; + + let items = find_track_items(&data).ok_or_else(|| { + PlaylistImportError::Parse("Apple Music track list not found".to_string()) + })?; + + let mut tracks = Vec::new(); + for item in items { + let title = item + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let artist = item + .get("artistName") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let duration_ms = item.get("duration").and_then(|v| v.as_u64()); + let provider_id = item + .get("contentDescriptor") + .and_then(|v| v.get("identifiers")) + .and_then(|v| v.get("storeAdamID")) + .and_then(|v| v.as_str()) + .map(|v| v.to_string()); + let provider_url = item + .get("contentDescriptor") + .and_then(|v| v.get("url")) + .and_then(|v| v.as_str()) + .map(|v| v.to_string()); + let album = item + .get("tertiaryLinks") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|v| v.get("title")) + .and_then(|v| v.as_str()) + .map(|v| v.to_string()); + + tracks.push(ImportTrack { + title, + artist, + album, + duration_ms, + isrc: None, + provider_id, + provider_url, + }); + } + + Ok(ImportPlaylist { + provider: ImportProvider::AppleMusic, + provider_id: playlist_id.to_string(), + name, + description, + tracks, + }) +} + +fn extract_script(html: &str, id: &str) -> Option { + let marker = format!("id=\"{}\"", id); + let start = html.find(&marker)?; + let script_start = html[start..].find('>')? + start + 1; + let script_end = html[script_start..].find("")? + script_start; + let raw = &html[script_start..script_end]; + Some(unescape_basic(raw)) +} + +fn find_track_items(data: &Value) -> Option> { + match data { + Value::Object(map) => { + if map.get("itemKind").and_then(|v| v.as_str()) == Some("trackLockup") { + let items = map.get("items").and_then(|v| v.as_array())?; + if !items.is_empty() { + return Some(items.iter().collect()); + } + } + + for value in map.values() { + if let Some(found) = find_track_items(value) { + return Some(found); + } + } + } + Value::Array(list) => { + for value in list { + if let Some(found) = find_track_items(value) { + return Some(found); + } + } + } + _ => {} + } + + None +} + +fn extract_meta(html: &str, property: &str) -> Option { + let needle = format!("property=\"{}\"", property); + let start = html.find(&needle)?; + let content_start = html[start..].find("content=\"")? + start + "content=\"".len(); + let content_end = html[content_start..].find('"')? + content_start; + Some(unescape_basic(&html[content_start..content_end])) +} + +fn unescape_basic(input: &str) -> String { + input + .replace(""", "\"") + .replace(""", "\"") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_playlist_id_table() { + // Editorial pl. id + assert_eq!( + parse_playlist_id( + "https://music.apple.com/us/playlist/top-100-global/pl.d25f5d1181894928af76c85c967f8f31" + ), + Some(( + "us".to_string(), + "pl.d25f5d1181894928af76c85c967f8f31".to_string() + )) + ); + // User pl.u- id + query strip + assert_eq!( + parse_playlist_id("https://music.apple.com/mx/playlist/mias/pl.u-abc123?l=en"), + Some(("mx".to_string(), "pl.u-abc123".to_string())), + ); + // Album URL is not a playlist + assert_eq!( + parse_playlist_id("https://music.apple.com/us/album/abbey-road/1441164426"), + None + ); + // Too few path segments + assert_eq!( + parse_playlist_id("https://music.apple.com/us/playlist"), + None + ); + // Wrong host + assert_eq!( + parse_playlist_id("https://example.com/us/playlist/x/pl.123"), + None + ); + } + + #[test] + fn detect_resource_song_album_track() { + assert!(matches!( + detect_resource("https://music.apple.com/us/song/hey-jude/1441164589"), + Some(super::super::MusicResource::Track { .. }) + )); + assert!(matches!( + detect_resource("https://music.apple.com/us/album/abbey-road/1441164426?i=1441164589"), + Some(super::super::MusicResource::Track { .. }) + )); + assert!(matches!( + detect_resource("https://music.apple.com/us/album/abbey-road/1441164426"), + Some(super::super::MusicResource::Album { .. }) + )); + assert!(matches!( + detect_resource("https://music.apple.com/us/playlist/x/pl.123"), + Some(super::super::MusicResource::Playlist { .. }) + )); + assert_eq!(detect_resource("https://example.com/us/album/x/1"), None); + } + + #[test] + fn extract_script_unescapes_serialized_server_data() { + let html = concat!( + "" + ); + assert_eq!( + extract_script(html, "serialized-server-data").as_deref(), + Some("[{\"itemKind\":\"trackLockup\"}]") + ); + } + + #[test] + fn extract_meta_reads_og_tags() { + let html = concat!( + "", + "" + ); + assert_eq!( + extract_meta(html, "og:title").as_deref(), + Some("My Playlist & More") + ); + assert_eq!(extract_meta(html, "og:description").as_deref(), Some("")); + assert_eq!(extract_meta(html, "og:image"), None); + } + + #[test] + fn unescape_basic_entities() { + assert_eq!( + unescape_basic(""a" "b" & <c>"), + "\"a\" \"b\" & " + ); + } + + #[test] + fn find_track_items_locates_track_lockup_anywhere() { + let data: Value = serde_json::from_str( + r#"[{"sections":[{"itemKind":"trackLockup","items":[ + {"title":"Hey Jude","artistName":"The Beatles","duration":431333}, + {"title":"Let It Be","artistName":"The Beatles","duration":243026} + ]}]}]"#, + ) + .unwrap(); + let items = find_track_items(&data).expect("found"); + assert_eq!(items.len(), 2); + assert_eq!(items[0]["title"].as_str(), Some("Hey Jude")); + assert_eq!(items[1]["artistName"].as_str(), Some("The Beatles")); + } + + #[test] + fn find_track_items_ignores_empty_lockups() { + let data: Value = serde_json::from_str(r#"{"itemKind":"trackLockup","items":[]}"#).unwrap(); + assert!(find_track_items(&data).is_none()); + } +} diff --git a/crates/qbz-playlist-import/src/providers/deezer.rs b/crates/qbz-playlist-import/src/providers/deezer.rs new file mode 100644 index 000000000..96a96867a --- /dev/null +++ b/crates/qbz-playlist-import/src/providers/deezer.rs @@ -0,0 +1,200 @@ +//! Deezer playlist import + +use serde_json::Value; + +use crate::errors::PlaylistImportError; +use crate::http::http; +use crate::models::{ImportPlaylist, ImportProvider, ImportTrack}; + +/// Detect if a URL is a Deezer track, album, or playlist. +/// +/// Deezer URLs: +/// - Track: `deezer.com/track/{id}` or `deezer.com/{locale}/track/{id}` +/// - Album: `deezer.com/album/{id}` or `deezer.com/{locale}/album/{id}` +/// - Playlist: `deezer.com/playlist/{id}` or `deezer.com/{locale}/playlist/{id}` +pub fn detect_resource(url: &str) -> Option { + if !url.contains("deezer.com") { + return None; + } + + // Playlist + if parse_playlist_id(url).is_some() { + return Some(super::MusicResource::Playlist { + provider: super::MusicProvider::Deezer, + }); + } + + let parts: Vec<&str> = url.split('/').collect(); + for (idx, part) in parts.iter().enumerate() { + match *part { + "track" => { + if parts.get(idx + 1).map(|s| !s.is_empty()).unwrap_or(false) { + return Some(super::MusicResource::Track { + provider: super::MusicProvider::Deezer, + url: url.to_string(), + }); + } + } + "album" => { + if parts.get(idx + 1).map(|s| !s.is_empty()).unwrap_or(false) { + return Some(super::MusicResource::Album { + provider: super::MusicProvider::Deezer, + url: url.to_string(), + }); + } + } + _ => {} + } + } + + None +} + +pub fn parse_playlist_id(url: &str) -> Option { + if !url.contains("deezer.com") { + return None; + } + + let parts: Vec<&str> = url.split('/').collect(); + for (idx, part) in parts.iter().enumerate() { + if *part == "playlist" { + let id = parts.get(idx + 1)?.split('?').next()?; + if !id.is_empty() { + return Some(id.to_string()); + } + } + } + + None +} + +pub async fn fetch_playlist(playlist_id: &str) -> Result { + let url = format!("https://api.deezer.com/playlist/{}", playlist_id); + let data: Value = http() + .get(&url) + .send() + .await + .map_err(|e| PlaylistImportError::Http(e.to_string()))? + .json() + .await + .map_err(|e| PlaylistImportError::Parse(e.to_string()))?; + + let name = data + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Deezer Playlist") + .to_string(); + let description = data + .get("description") + .and_then(|v| v.as_str()) + .map(|v| v.to_string()) + .filter(|v| !v.is_empty()); + + let mut tracks = Vec::new(); + let items = data + .get("tracks") + .and_then(|v| v.get("data")) + .and_then(|v| v.as_array()) + .ok_or_else(|| PlaylistImportError::Parse("Deezer tracks missing".to_string()))?; + + for item in items { + let title = item + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let artist = item + .get("artist") + .and_then(|v| v.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let album = item + .get("album") + .and_then(|v| v.get("title")) + .and_then(|v| v.as_str()) + .map(|v| v.to_string()); + let duration_ms = item + .get("duration") + .and_then(|v| v.as_u64()) + .map(|v| v * 1000); + let isrc = item + .get("isrc") + .and_then(|v| v.as_str()) + .map(|v| v.to_string()); + let provider_id = item + .get("id") + .and_then(|v| v.as_u64()) + .map(|v| v.to_string()); + let provider_url = item + .get("link") + .and_then(|v| v.as_str()) + .map(|v| v.to_string()); + + tracks.push(ImportTrack { + title, + artist, + album, + duration_ms, + isrc, + provider_id, + provider_url, + }); + } + + Ok(ImportPlaylist { + provider: ImportProvider::Deezer, + provider_id: playlist_id.to_string(), + name, + description, + tracks, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_playlist_id_table() { + let cases: &[(&str, Option<&str>)] = &[ + ( + "https://www.deezer.com/playlist/1234567890", + Some("1234567890"), + ), + ( + "https://www.deezer.com/en/playlist/1234567890", + Some("1234567890"), + ), + ( + "https://www.deezer.com/en/playlist/1234567890?utm_source=x", + Some("1234567890"), + ), + ("https://www.deezer.com/en/album/1234567890", None), + ("https://www.deezer.com/en/playlist/", None), + ("https://example.com/playlist/123", None), + ]; + + for (url, expected) in cases { + assert_eq!(parse_playlist_id(url).as_deref(), *expected, "url: {}", url); + } + } + + #[test] + fn detect_resource_track_album_playlist() { + assert!(matches!( + detect_resource("https://www.deezer.com/en/playlist/123"), + Some(super::super::MusicResource::Playlist { .. }) + )); + assert!(matches!( + detect_resource("https://www.deezer.com/track/456"), + Some(super::super::MusicResource::Track { .. }) + )); + assert!(matches!( + detect_resource("https://www.deezer.com/fr/album/789"), + Some(super::super::MusicResource::Album { .. }) + )); + assert_eq!(detect_resource("https://example.com/track/1"), None); + assert_eq!(detect_resource("https://www.deezer.com/"), None); + } +} diff --git a/crates/qbz-playlist-import/src/providers/mod.rs b/crates/qbz-playlist-import/src/providers/mod.rs new file mode 100644 index 000000000..6ee62d654 --- /dev/null +++ b/crates/qbz-playlist-import/src/providers/mod.rs @@ -0,0 +1,220 @@ +//! Provider implementations + +pub mod apple; +pub mod deezer; +pub mod spotify; +pub mod tidal; + +use serde::{Deserialize, Serialize}; + +use crate::errors::PlaylistImportError; +use crate::models::ImportPlaylist; + +/// Which streaming platform a music link belongs to. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum MusicProvider { + Spotify, + AppleMusic, + Tidal, + Deezer, +} + +/// The kind of resource a music URL points to. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum MusicResource { + /// A native Qobuz URL — resolve directly. + Qobuz, + /// A single track on a third-party platform. + Track { + provider: MusicProvider, + url: String, + }, + /// An album on a third-party platform. + Album { + provider: MusicProvider, + url: String, + }, + /// A playlist — should be redirected to the Playlist Importer. + Playlist { provider: MusicProvider }, + /// A song.link / album.link / odesli.co URL — resolve via Odesli API. + SongLink { url: String }, +} + +/// Detect what kind of music resource a URL points to. +/// +/// Returns `None` for URLs that don't match any supported platform. +pub fn detect_music_resource(url: &str) -> Option { + let url = url.trim(); + if url.is_empty() { + return None; + } + + // 1. Qobuz — resolve_link() handles this natively + if qbz_qobuz::resolve_link(url).is_ok() { + return Some(MusicResource::Qobuz); + } + + // 2. song.link / album.link / odesli.co URLs + let lower = url.to_ascii_lowercase(); + if lower.contains("song.link/") || lower.contains("album.link/") || lower.contains("odesli.co/") + { + return Some(MusicResource::SongLink { + url: url.to_string(), + }); + } + + // 3. Per-provider detection (track/album/playlist) + if let Some(resource) = spotify::detect_resource(url) { + return Some(resource); + } + if let Some(resource) = apple::detect_resource(url) { + return Some(resource); + } + if let Some(resource) = tidal::detect_resource(url) { + return Some(resource); + } + if let Some(resource) = deezer::detect_resource(url) { + return Some(resource); + } + + None +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProviderKind { + Spotify { + playlist_id: String, + }, + AppleMusic { + storefront: String, + playlist_id: String, + }, + Tidal { + playlist_id: String, + }, + Deezer { + playlist_id: String, + }, +} + +pub fn detect_provider(url: &str) -> Result { + if let Some(id) = spotify::parse_playlist_id(url) { + return Ok(ProviderKind::Spotify { playlist_id: id }); + } + if let Some((storefront, id)) = apple::parse_playlist_id(url) { + return Ok(ProviderKind::AppleMusic { + storefront, + playlist_id: id, + }); + } + if let Some(id) = tidal::parse_playlist_id(url) { + return Ok(ProviderKind::Tidal { playlist_id: id }); + } + if let Some(id) = deezer::parse_playlist_id(url) { + return Ok(ProviderKind::Deezer { playlist_id: id }); + } + + Err(PlaylistImportError::UnsupportedProvider(url.to_string())) +} + +/// Fetch playlist (proxy handles credentials) +pub async fn fetch_playlist(kind: ProviderKind) -> Result { + match kind { + ProviderKind::Spotify { playlist_id } => spotify::fetch_playlist(&playlist_id).await, + ProviderKind::AppleMusic { + storefront, + playlist_id, + } => apple::fetch_playlist(&storefront, &playlist_id).await, + // Default Tidal storefront ("US"); callers wanting another country + // call tidal::fetch_playlist directly (e.g. with an env read at + // their edge — the Tauri original read TIDAL_COUNTRY_CODE here). + ProviderKind::Tidal { playlist_id } => tidal::fetch_playlist(&playlist_id, None).await, + ProviderKind::Deezer { playlist_id } => deezer::fetch_playlist(&playlist_id).await, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_provider_table() { + // Spotify URI / URL / embed forms + query strip + assert_eq!( + detect_provider("spotify:playlist:37i9dQZF1DXcBWIGoYBM5M").unwrap(), + ProviderKind::Spotify { + playlist_id: "37i9dQZF1DXcBWIGoYBM5M".to_string() + } + ); + assert_eq!( + detect_provider("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M?si=abc") + .unwrap(), + ProviderKind::Spotify { + playlist_id: "37i9dQZF1DXcBWIGoYBM5M".to_string() + } + ); + assert_eq!( + detect_provider("https://open.spotify.com/embed/playlist/37i9dQZF1DXcBWIGoYBM5M") + .unwrap(), + ProviderKind::Spotify { + playlist_id: "37i9dQZF1DXcBWIGoYBM5M".to_string() + } + ); + + // Apple storefront + pl. ids + assert_eq!( + detect_provider("https://music.apple.com/us/playlist/top-100-global/pl.d25f5d1181894928af76c85c967f8f31") + .unwrap(), + ProviderKind::AppleMusic { + storefront: "us".to_string(), + playlist_id: "pl.d25f5d1181894928af76c85c967f8f31".to_string() + } + ); + + // Tidal + assert_eq!( + detect_provider( + "https://tidal.com/browse/playlist/1b418bb8-90a7-4f87-901d-707993838346" + ) + .unwrap(), + ProviderKind::Tidal { + playlist_id: "1b418bb8-90a7-4f87-901d-707993838346".to_string() + } + ); + + // Deezer + assert_eq!( + detect_provider("https://www.deezer.com/en/playlist/1234567890").unwrap(), + ProviderKind::Deezer { + playlist_id: "1234567890".to_string() + } + ); + + // Rejects + assert!(detect_provider("https://example.com/playlist/1").is_err()); + assert!(detect_provider("https://open.spotify.com/track/abc").is_err()); + assert!(detect_provider("").is_err()); + } + + #[test] + fn detect_music_resource_song_link_and_none() { + assert_eq!( + detect_music_resource("https://song.link/i/1440857781"), + Some(MusicResource::SongLink { + url: "https://song.link/i/1440857781".to_string() + }) + ); + assert_eq!(detect_music_resource(""), None); + assert_eq!(detect_music_resource("https://example.com/whatever"), None); + } + + #[test] + fn detect_music_resource_playlist_routes_to_importer() { + assert_eq!( + detect_music_resource("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M"), + Some(MusicResource::Playlist { + provider: MusicProvider::Spotify + }) + ); + } +} diff --git a/crates/qbz-playlist-import/src/providers/spotify.rs b/crates/qbz-playlist-import/src/providers/spotify.rs new file mode 100644 index 000000000..dbb6957bc --- /dev/null +++ b/crates/qbz-playlist-import/src/providers/spotify.rs @@ -0,0 +1,299 @@ +//! Spotify playlist import +//! +//! As of 2026-03-06, Spotify API access via client_credentials is no longer available. +//! All playlist imports now use the embed page scraping fallback. +//! Embed is limited to ~50 tracks and provides no ISRC or album data. + +use serde_json::Value; + +use crate::errors::PlaylistImportError; +use crate::http::http; +use crate::models::{ImportPlaylist, ImportProvider, ImportTrack}; + +/// Detect if a URL is a Spotify track, album, or playlist. +pub fn detect_resource(url: &str) -> Option { + let lower = url.to_ascii_lowercase(); + if !lower.contains("spotify.com/") && !lower.starts_with("spotify:") { + return None; + } + + // Playlist check first (so parse_playlist_id takes priority for playlists) + if parse_playlist_id(url).is_some() { + return Some(super::MusicResource::Playlist { + provider: super::MusicProvider::Spotify, + }); + } + + // Track: open.spotify.com/track/ or spotify:track: + if lower.contains("/track/") || lower.contains(":track:") { + return Some(super::MusicResource::Track { + provider: super::MusicProvider::Spotify, + url: url.to_string(), + }); + } + + // Album: open.spotify.com/album/ or spotify:album: + if lower.contains("/album/") || lower.contains(":album:") { + return Some(super::MusicResource::Album { + provider: super::MusicProvider::Spotify, + url: url.to_string(), + }); + } + + None +} + +pub fn parse_playlist_id(url: &str) -> Option { + if let Some(rest) = url.strip_prefix("spotify:playlist:") { + if !rest.is_empty() { + return Some(rest.to_string()); + } + } + + let patterns = [ + "open.spotify.com/playlist/", + "open.spotify.com/embed/playlist/", + ]; + for pattern in patterns { + if let Some(idx) = url.find(pattern) { + let mut part = &url[idx + pattern.len()..]; + if let Some(end) = part.find('?') { + part = &part[..end]; + } + if !part.is_empty() { + return Some(part.to_string()); + } + } + } + + None +} + +/// Fetch track or album metadata from Spotify embed page. +/// Returns (title, artist) if successful. +pub async fn fetch_embed_metadata(entity_type: &str, entity_id: &str) -> Option<(String, String)> { + let url = format!( + "https://open.spotify.com/embed/{}/{}", + entity_type, entity_id + ); + let html = http().get(&url).send().await.ok()?.text().await.ok()?; + let json_text = extract_script(&html, "__NEXT_DATA__")?; + let data: Value = serde_json::from_str(&json_text).ok()?; + + let entity = data + .get("props")? + .get("pageProps")? + .get("state")? + .get("data")? + .get("entity")?; + + let title = entity + .get("title") + .or_else(|| entity.get("name")) + .and_then(|v| v.as_str())? + .to_string(); + + // Tracks have "artists" array, albums have "subtitle" string + let artist = entity + .get("artists") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|a| a.get("name").and_then(|v| v.as_str())) + .collect::>() + .join(", ") + }) + .or_else(|| { + entity + .get("subtitle") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_default(); + + if title.is_empty() { + return None; + } + + Some((title, artist)) +} + +pub async fn fetch_playlist(playlist_id: &str) -> Result { + log::info!( + "Spotify: fetching playlist {} via embed (API no longer available)", + playlist_id + ); + fetch_playlist_from_embed(playlist_id).await +} + +async fn fetch_playlist_from_embed( + playlist_id: &str, +) -> Result { + let url = format!("https://open.spotify.com/embed/playlist/{}", playlist_id); + let html = http() + .get(&url) + .send() + .await + .map_err(|e| PlaylistImportError::Http(e.to_string()))? + .text() + .await + .map_err(|e| PlaylistImportError::Http(e.to_string()))?; + + let json_text = extract_script(&html, "__NEXT_DATA__").ok_or_else(|| { + PlaylistImportError::Parse("Spotify embed missing __NEXT_DATA__".to_string()) + })?; + + let data: Value = + serde_json::from_str(&json_text).map_err(|e| PlaylistImportError::Parse(e.to_string()))?; + + let entity = data + .get("props") + .and_then(|v| v.get("pageProps")) + .and_then(|v| v.get("state")) + .and_then(|v| v.get("data")) + .and_then(|v| v.get("entity")) + .ok_or_else(|| PlaylistImportError::Parse("Spotify embed missing entity".to_string()))?; + + let name = entity + .get("title") + .or_else(|| entity.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("Spotify Playlist") + .to_string(); + + let mut tracks = Vec::new(); + let track_list = entity + .get("trackList") + .and_then(|v| v.as_array()) + .ok_or_else(|| PlaylistImportError::Parse("Spotify embed missing trackList".to_string()))?; + + for track in track_list { + let title = track + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let artist = track + .get("subtitle") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let duration_ms = track.get("duration").and_then(|v| v.as_u64()); + let uri = track.get("uri").and_then(|v| v.as_str()).unwrap_or(""); + let provider_id = uri + .split(':') + .last() + .filter(|v| !v.is_empty()) + .map(|v| v.to_string()); + let provider_url = provider_id + .as_ref() + .map(|id| format!("https://open.spotify.com/track/{}", id)); + + tracks.push(ImportTrack { + title, + artist, + album: None, + duration_ms, + isrc: None, + provider_id, + provider_url, + }); + } + + log::info!( + "Spotify: embed returned {} tracks for '{}' (embed limit is ~50, no ISRC/album data)", + tracks.len(), + name + ); + + Ok(ImportPlaylist { + provider: ImportProvider::Spotify, + provider_id: playlist_id.to_string(), + name, + description: None, + tracks, + }) +} + +fn extract_script(html: &str, id: &str) -> Option { + let marker = format!("id=\"{}\"", id); + let start = html.find(&marker)?; + let script_start = html[start..].find('>')? + start + 1; + let script_end = html[script_start..].find("")? + script_start; + Some(html[script_start..script_end].to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_playlist_id_table() { + let cases: &[(&str, Option<&str>)] = &[ + ( + "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M", + Some("37i9dQZF1DXcBWIGoYBM5M"), + ), + ( + "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", + Some("37i9dQZF1DXcBWIGoYBM5M"), + ), + ( + "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M?si=xyz&utm=1", + Some("37i9dQZF1DXcBWIGoYBM5M"), + ), + ( + "https://open.spotify.com/embed/playlist/37i9dQZF1DXcBWIGoYBM5M", + Some("37i9dQZF1DXcBWIGoYBM5M"), + ), + ("spotify:playlist:", None), + ("https://open.spotify.com/playlist/", None), + ("https://open.spotify.com/track/abc", None), + ("https://example.com/", None), + ]; + + for (url, expected) in cases { + assert_eq!(parse_playlist_id(url).as_deref(), *expected, "url: {}", url); + } + } + + #[test] + fn detect_resource_track_album_playlist() { + assert_eq!( + detect_resource("https://open.spotify.com/playlist/abc"), + Some(super::super::MusicResource::Playlist { + provider: super::super::MusicProvider::Spotify + }) + ); + assert!(matches!( + detect_resource("https://open.spotify.com/track/abc"), + Some(super::super::MusicResource::Track { .. }) + )); + assert!(matches!( + detect_resource("spotify:album:abc"), + Some(super::super::MusicResource::Album { .. }) + )); + assert_eq!(detect_resource("https://example.com/track/abc"), None); + } + + #[test] + fn extract_script_pulls_next_data_payload() { + let html = concat!( + "", + "" + ); + let json_text = extract_script(html, "__NEXT_DATA__").expect("script found"); + let data: Value = serde_json::from_str(&json_text).expect("valid JSON"); + let title = data["props"]["pageProps"]["state"]["data"]["entity"]["title"] + .as_str() + .unwrap(); + assert_eq!(title, "My Mix"); + } + + #[test] + fn extract_script_missing_id_is_none() { + assert_eq!(extract_script("", "__NEXT_DATA__"), None); + } +} diff --git a/crates/qbz-playlist-import/src/providers/tidal.rs b/crates/qbz-playlist-import/src/providers/tidal.rs new file mode 100644 index 000000000..a29bc28d7 --- /dev/null +++ b/crates/qbz-playlist-import/src/providers/tidal.rs @@ -0,0 +1,469 @@ +//! Tidal playlist import (OpenAPI v2) + +use std::time::Duration; + +use serde_json::Value; +use tokio::time::sleep; + +use crate::errors::PlaylistImportError; +use crate::http::http; +use crate::models::{ImportPlaylist, ImportProvider, ImportTrack}; + +const RATE_LIMIT_DELAY_MS: u64 = 200; // Delay between API calls to avoid 429 + +const TIDAL_API_BASE: &str = "https://openapi.tidal.com/v2"; +const DEFAULT_COUNTRY_CODE: &str = "US"; + +/// Detect if a URL is a Tidal track, album, or playlist. +/// +/// Tidal URLs: +/// - Track: `tidal.com/browse/track/{id}` or `tidal.com/track/{id}` +/// - Album: `tidal.com/browse/album/{id}` or `tidal.com/album/{id}` +/// - Playlist: `tidal.com/browse/playlist/{id}` or `tidal.com/playlist/{id}` +pub fn detect_resource(url: &str) -> Option { + if !url.contains("tidal.com") { + return None; + } + + // Playlist + if parse_playlist_id(url).is_some() { + return Some(super::MusicResource::Playlist { + provider: super::MusicProvider::Tidal, + }); + } + + let lower = url.to_ascii_lowercase(); + + // Track + if lower.contains("/track/") || lower.contains("/browse/track/") { + return Some(super::MusicResource::Track { + provider: super::MusicProvider::Tidal, + url: url.to_string(), + }); + } + + // Album + if lower.contains("/album/") || lower.contains("/browse/album/") { + return Some(super::MusicResource::Album { + provider: super::MusicProvider::Tidal, + url: url.to_string(), + }); + } + + None +} + +pub fn parse_playlist_id(url: &str) -> Option { + if !url.contains("tidal.com") { + return None; + } + + let patterns = ["/browse/playlist/", "/playlist/"]; + for pattern in patterns { + if let Some(idx) = url.find(pattern) { + let mut part = &url[idx + pattern.len()..]; + if let Some(end) = part.find('?') { + part = &part[..end]; + } + if !part.is_empty() { + return Some(part.to_string()); + } + } + } + + None +} + +/// Fetch a Tidal playlist. +/// +/// `country_code` replaces the Tauri original's `TIDAL_COUNTRY_CODE` env read +/// — `None` keeps the same "US" default; callers wanting the env behavior +/// read it at their edge and pass `Some(..)`. +pub async fn fetch_playlist( + playlist_id: &str, + country_code: Option<&str>, +) -> Result { + let token = get_app_token().await?; + let country_code = country_code.unwrap_or(DEFAULT_COUNTRY_CODE).to_string(); + + let client = http(); + let meta_url = format!("{}/playlists/{}", TIDAL_API_BASE, playlist_id); + let resp = client + .get(&meta_url) + .header("Authorization", format!("Bearer {}", token)) + .query(&[("countryCode", &country_code)]) + .send() + .await + .map_err(|e| PlaylistImportError::Http(e.to_string()))?; + + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| PlaylistImportError::Parse(e.to_string()))?; + + if !status.is_success() { + return Err(PlaylistImportError::Http(format!( + "Tidal playlist fetch failed: {} - {}", + status, body + ))); + } + + let meta: Value = serde_json::from_str(&body) + .map_err(|e| PlaylistImportError::Parse(format!("Invalid playlist JSON: {}", e)))?; + + let name = meta + .get("data") + .and_then(|v| v.get("attributes")) + .and_then(|v| v.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("Tidal Playlist") + .to_string(); + let description = meta + .get("data") + .and_then(|v| v.get("attributes")) + .and_then(|v| v.get("description")) + .and_then(|v| v.as_str()) + .map(|v| v.to_string()) + .filter(|v| !v.is_empty()); + + let track_ids = fetch_track_ids(client, &token, playlist_id, &country_code).await?; + let tracks = fetch_tracks_by_ids(client, &token, &track_ids, &country_code).await?; + + Ok(ImportPlaylist { + provider: ImportProvider::Tidal, + provider_id: playlist_id.to_string(), + name, + description, + tracks, + }) +} + +async fn fetch_track_ids( + client: &reqwest::Client, + token: &str, + playlist_id: &str, + country_code: &str, +) -> Result, PlaylistImportError> { + let mut ids = Vec::new(); + let mut next_path = format!("/playlists/{}/relationships/items?limit=100", playlist_id); + + loop { + let url = format!("{}{}", TIDAL_API_BASE, next_path); + sleep(Duration::from_millis(RATE_LIMIT_DELAY_MS)).await; + + let mut request = client + .get(&url) + .header("Authorization", format!("Bearer {}", token)); + if !next_path.contains("countryCode=") { + request = request.query(&[("countryCode", country_code)]); + } + + let resp = request + .send() + .await + .map_err(|e| PlaylistImportError::Http(e.to_string()))?; + + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| PlaylistImportError::Parse(e.to_string()))?; + + if !status.is_success() { + return Err(PlaylistImportError::Http(format!( + "Tidal track IDs fetch failed: {} - {}", + status, body + ))); + } + + let response: Value = serde_json::from_str(&body) + .map_err(|e| PlaylistImportError::Parse(format!("Invalid track IDs JSON: {}", e)))?; + + if let Some(data) = response.get("data").and_then(|v| v.as_array()) { + for item in data { + if let Some(id) = item.get("id").and_then(|v| v.as_str()) { + ids.push(id.to_string()); + } + } + } + + let next = response + .get("links") + .and_then(|v| v.get("next")) + .and_then(|v| v.as_str()) + .map(|v| v.to_string()); + + match next { + Some(path) if !path.is_empty() => { + next_path = path; + } + _ => break, + } + } + + Ok(ids) +} + +async fn fetch_tracks_by_ids( + client: &reqwest::Client, + token: &str, + track_ids: &[String], + country_code: &str, +) -> Result, PlaylistImportError> { + let mut tracks = Vec::new(); + let mut chunk_start = 0usize; + let chunk_size = 20usize; // Tidal API limit + + while chunk_start < track_ids.len() { + let end = (chunk_start + chunk_size).min(track_ids.len()); + let chunk = &track_ids[chunk_start..end]; + + let url = format!("{}/tracks", TIDAL_API_BASE); + sleep(Duration::from_millis(RATE_LIMIT_DELAY_MS)).await; + + let resp = client + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + .query(&[ + ("filter[id]", chunk.join(",")), + ("include", "artists,albums".to_string()), + ("countryCode", country_code.to_string()), + ]) + .send() + .await + .map_err(|e| PlaylistImportError::Http(e.to_string()))?; + + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| PlaylistImportError::Parse(e.to_string()))?; + + if !status.is_success() { + return Err(PlaylistImportError::Http(format!( + "Tidal tracks fetch failed: {} - {}", + status, body + ))); + } + + let response: Value = serde_json::from_str(&body) + .map_err(|e| PlaylistImportError::Parse(format!("Invalid tracks JSON: {}", e)))?; + + let included = response + .get("included") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + let mut artist_map = std::collections::HashMap::new(); + let mut album_map = std::collections::HashMap::new(); + + for item in included { + if let Some(item_type) = item.get("type").and_then(|v| v.as_str()) { + let id = item.get("id").and_then(|v| v.as_str()).unwrap_or(""); + if id.is_empty() { + continue; + } + + match item_type { + "artists" => { + if let Some(name) = item + .get("attributes") + .and_then(|v| v.get("name")) + .and_then(|v| v.as_str()) + { + artist_map.insert(id.to_string(), name.to_string()); + } + } + "albums" => { + if let Some(name) = item + .get("attributes") + .and_then(|v| v.get("title")) + .and_then(|v| v.as_str()) + { + album_map.insert(id.to_string(), name.to_string()); + } + } + _ => {} + } + } + } + + if let Some(data) = response.get("data").and_then(|v| v.as_array()) { + for item in data { + let title = item + .get("attributes") + .and_then(|v| v.get("title")) + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let isrc = item + .get("attributes") + .and_then(|v| v.get("isrc")) + .and_then(|v| v.as_str()) + .map(|v| v.to_string()); + let duration_ms = item + .get("attributes") + .and_then(|v| v.get("duration")) + .and_then(|v| v.as_str()) + .and_then(|v| parse_duration_ms(v)); + + let artist = item + .get("relationships") + .and_then(|v| v.get("artists")) + .and_then(|v| v.get("data")) + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|v| v.get("id")) + .and_then(|v| v.as_str()) + .and_then(|id| artist_map.get(id)) + .cloned() + .unwrap_or_else(|| "Unknown".to_string()); + + let album = item + .get("relationships") + .and_then(|v| v.get("albums")) + .and_then(|v| v.get("data")) + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|v| v.get("id")) + .and_then(|v| v.as_str()) + .and_then(|id| album_map.get(id)) + .cloned(); + + let provider_id = item + .get("id") + .and_then(|v| v.as_str()) + .map(|v| v.to_string()); + + tracks.push(ImportTrack { + title, + artist, + album, + duration_ms, + isrc, + provider_id, + provider_url: None, + }); + } + } + + chunk_start = end; + } + + Ok(tracks) +} + +fn parse_duration_ms(value: &str) -> Option { + if !value.starts_with('P') { + return None; + } + + let mut seconds = 0u64; + let mut parsed_any = false; + let mut num = String::new(); + for ch in value.chars() { + if ch.is_ascii_digit() { + num.push(ch); + continue; + } + + if num.is_empty() { + continue; + } + + let value_num: u64 = num.parse().ok()?; + parsed_any = true; + match ch { + 'H' => seconds += value_num * 3600, + 'M' => seconds += value_num * 60, + 'S' => seconds += value_num, + _ => {} + } + num.clear(); + } + + if !parsed_any { + None + } else { + Some(seconds * 1000) + } +} + +async fn get_app_token() -> Result { + // Proxy handles credentials + let url = format!("{}/tidal/token", crate::QBZ_PROXY_BASE); + + let response: Value = http() + .get(&url) + .header(reqwest::header::USER_AGENT, crate::http::USER_AGENT) + .send() + .await + .map_err(|e| PlaylistImportError::Http(e.to_string()))? + .json() + .await + .map_err(|e| PlaylistImportError::Parse(e.to_string()))?; + + response + .get("access_token") + .and_then(|v| v.as_str()) + .map(|v| v.to_string()) + .ok_or_else(|| PlaylistImportError::Parse("Tidal proxy missing access_token".to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_playlist_id_table() { + let cases: &[(&str, Option<&str>)] = &[ + ( + "https://tidal.com/browse/playlist/1b418bb8-90a7-4f87-901d-707993838346", + Some("1b418bb8-90a7-4f87-901d-707993838346"), + ), + ( + "https://listen.tidal.com/playlist/1b418bb8-90a7-4f87-901d-707993838346", + Some("1b418bb8-90a7-4f87-901d-707993838346"), + ), + ("https://tidal.com/playlist/abc?u=1", Some("abc")), + ("https://tidal.com/browse/album/123", None), + ("https://tidal.com/playlist/", None), + ("https://example.com/playlist/abc", None), + ]; + + for (url, expected) in cases { + assert_eq!(parse_playlist_id(url).as_deref(), *expected, "url: {}", url); + } + } + + #[test] + fn detect_resource_track_album_playlist() { + assert!(matches!( + detect_resource("https://tidal.com/browse/playlist/abc"), + Some(super::super::MusicResource::Playlist { .. }) + )); + assert!(matches!( + detect_resource("https://tidal.com/browse/track/12345"), + Some(super::super::MusicResource::Track { .. }) + )); + assert!(matches!( + detect_resource("https://tidal.com/album/67890"), + Some(super::super::MusicResource::Album { .. }) + )); + assert_eq!(detect_resource("https://example.com/track/1"), None); + } + + #[test] + fn parse_duration_ms_iso8601_forms() { + assert_eq!(parse_duration_ms("PT3M21S"), Some(201_000)); + assert_eq!(parse_duration_ms("PT1H2M3S"), Some(3_723_000)); + assert_eq!(parse_duration_ms("PT45S"), Some(45_000)); + // No leading P + assert_eq!(parse_duration_ms("3M21S"), None); + // No digit/unit pairs at all + assert_eq!(parse_duration_ms("PT"), None); + assert_eq!(parse_duration_ms(""), None); + } +} diff --git a/crates/qbz-playlist-import/src/sink.rs b/crates/qbz-playlist-import/src/sink.rs new file mode 100644 index 000000000..c4b740491 --- /dev/null +++ b/crates/qbz-playlist-import/src/sink.rs @@ -0,0 +1,47 @@ +//! Frontend-agnostic progress reporting. +//! +//! Replaces the Tauri `AppHandle::emit("import:phase" | "import:progress")` +//! sites. Emission is fire-and-forget and infallible — the Tauri emits were +//! all `let _ =`. + +use serde::Serialize; + +use crate::models::ImportProgress; + +/// Import phase, mirroring the Tauri `import:phase` payloads +/// (`{"phase": "matching" | "creating" | "adding"}`). +/// +/// `Creating` and `Adding` re-fire once per created playlist part. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ImportPhase { + Matching, + Creating, + /// NOTE (owner decision, deliberate deviation from the Tauri UI): + /// frontends should render the status line during this phase as + /// "Adding tracks: {current} / {total}" — the Tauri modal reused the + /// "Matching tracks…" string here; do not carry that quirk. + Adding, +} + +/// One event from the import pipeline, mirroring the two Tauri events. +#[derive(Debug, Clone, Serialize)] +pub enum ImportEvent { + Phase(ImportPhase), + /// Existing wire model, unchanged shape. While matching: one per track + /// (high frequency), `current_track` = "Artist - Title". While adding: + /// one per 50-track chunk (chunk counts, not tracks), `current_track` = + /// "Part i/n" iff multi-part. + Progress(ImportProgress), +} + +/// Receives progress events from [`crate::import_public_playlist`]. +pub trait ImportProgressSink: Send + Sync { + fn emit(&self, event: ImportEvent); +} + +impl ImportProgressSink for F { + fn emit(&self, event: ImportEvent) { + self(event) + } +} diff --git a/crates/qbz-slint/Cargo.toml b/crates/qbz-slint/Cargo.toml index 5e7462e65..277ac1a75 100644 --- a/crates/qbz-slint/Cargo.toml +++ b/crates/qbz-slint/Cargo.toml @@ -56,6 +56,7 @@ qbz-plex = { path = "../qbz-plex" } qbz-offline-cache = { path = "../qbz-offline-cache" } qbz-models = { path = "../qbz-models" } qbz-mixtape = { path = "../qbz-mixtape" } +qbz-playlist-import = { path = "../qbz-playlist-import" } qbz-qobuz = { path = "../qbz-qobuz" } qbz-lyrics = { path = "../qbz-lyrics" } qbz-integrations = { path = "../qbz-integrations" } diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index fc5a9b636..b086bd8b1 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -74,6 +74,7 @@ mod offline_favorites; mod offline_manager; mod offline_mode; mod playlist; +mod playlist_import; mod playlist_manager; mod playlist_snapshot; mod plex_auth; @@ -289,6 +290,19 @@ async fn enter_shell( }); } + // Same for favorite ALBUMS — seeds fav_cache so the album header heart is + // correct from first open without visiting the Favorites view. + { + let runtime = runtime.clone(); + tokio::spawn(async move { + if crate::offline_mode::engine().is_offline() { + return; + } + let ids = favorites::favorite_album_ids(&runtime).await; + let _ = tokio::task::spawn_blocking(move || fav_cache::set_all_albums(ids)).await; + }); + } + // Load Audio + Playback settings into the Settings page in the // background — store reads and device enumeration are blocking. spawn_settings_snapshot_load(runtime.clone(), weak.clone(), settings_ctx.clone()); @@ -709,6 +723,20 @@ fn set_row_cache_status(window: &AppWindow, track_id: &str, status: i32, progres hero.cache_progress = progress; search.set_most_popular_track(hero); } + + // Keep the album header's "fully cached" gate live as the album's own + // rows flip to ready (drives Make-available-offline -> Refresh in the + // ⋯ menu). Only the open album view consults it. + { + let album = window.global::(); + let tracks = album.get_tracks(); + let n = tracks.row_count(); + let fully = n > 0 + && (0..n).all(|i| tracks.row_data(i).is_some_and(|t| t.cache_status == 3)); + if album.get_album_fully_cached() != fully { + album.set_album_fully_cached(fully); + } + } } /// Toggle the unlocking (padlock) flag of every visible row matching @@ -1042,6 +1070,48 @@ thread_local! { /// Debounce timer for the header live search — restarted on every /// keystroke, fires the search 300 ms after typing stops. static SEARCH_DEBOUNCE: slint::Timer = slint::Timer::default(); + + /// Stash for the "Duplicate tracks" confirm sub-modal. Slint can't hold a + /// `Vec` ergonomically, so when a Qobuz→Qobuz add finds duplicates we + /// park the full context here and the DuplicateConfirmActions handlers read + /// it back. Cleared on add-all / add-new-only / cancel. The tuple is + /// `(playlist_id, all_track_ids, duplicate_track_ids, playlist_name)`. + static DUP_CONFIRM_STASH: std::cell::RefCell< + Option<(u64, Vec, std::collections::HashSet, String)> + > = const { std::cell::RefCell::new(None) }; +} + +/// Look up a playlist's display name from the picker state model by id +/// (the picker only carries names UI-side in `PlaylistPickItem`). Used for +/// the "Added N tracks to " success toast. Falls back to an empty +/// string when the id is not found. +fn picker_playlist_name(w: &AppWindow, id: &str) -> String { + use slint::Model; + let model = w.global::().get_playlists(); + for i in 0..model.row_count() { + if let Some(item) = model.row_data(i) { + if item.id == id { + return item.name.to_string(); + } + } + } + String::new() +} + +/// Success toast for a playlist add ("Added N tracks to "). Hops +/// onto the event loop, so it is safe to call from a worker task. An empty +/// `name` degrades to "Added N tracks". The count is the number actually +/// written. +fn toast_added_tracks(weak: &slint::Weak, count: usize, name: String) { + if count == 0 { + return; + } + let msg = if name.is_empty() { + format!("Added {count} tracks") + } else { + format!("Added {count} tracks to {name}") + }; + crate::toast::success_weak(weak, msg); } /// Run a search and show the results view. Shared by the search-submit @@ -5042,6 +5112,9 @@ fn main() -> Result<(), Box> { handle.spawn(async move { match runtime.core().add_favorite("album", &album_id).await { Ok(()) => { + // Keep the favorite-album cache in sync so the + // album-header heart reflects a grid favorite. + crate::fav_cache::set_album(&album_id, true); crate::toast::success_weak(&weak, "Added to favorites"); } Err(e) => { @@ -5051,12 +5124,130 @@ fn main() -> Result<(), Box> { } }); } + ("album", "favorite-toggle") => { + // The album-header heart: a TRUE toggle that reflects the + // favorite-album cache (the grid "favorite" arm above stays + // add-only). Optimistic on the open header, reconciled on + // the server result. + let Some(w) = weak.upgrade() else { + return; + }; + let was_fav = crate::fav_cache::is_album_favorite(&id); + let new_state = !was_fav; + let st = w.global::(); + let is_open = st.get_id() == id.as_str(); + if is_open { + st.set_is_favorite(new_state); + st.set_favorite_loading(true); + } + let runtime = runtime.clone(); + let weak = weak.clone(); + let album_id = id.clone(); + handle.spawn(async move { + let res = if new_state { + runtime.core().add_favorite("album", &album_id).await + } else { + runtime.core().remove_favorite("album", &album_id).await + }; + let ok = res.is_ok(); + if let Err(e) = &res { + log::error!( + "[qbz-slint] toggle favorite album {album_id} failed: {e}" + ); + } + let _ = weak.upgrade_in_event_loop(move |w| { + let st = w.global::(); + let open_now = st.get_id() == album_id.as_str(); + if ok { + crate::fav_cache::set_album(&album_id, new_state); + if open_now { + st.set_favorite_loading(false); + st.set_is_favorite(new_state); + } + crate::toast::success( + &w, + if new_state { + "Added to favorites" + } else { + "Removed from favorites" + }, + ); + } else { + if open_now { + st.set_favorite_loading(false); + st.set_is_favorite(was_fav); + } + crate::toast::error(&w, "Couldn't update favorites"); + } + }); + }); + } ("album", "cache") => offline_cache::cache_album( runtime.clone(), weak.clone(), handle.clone(), id, ), + ("album", "recache") => offline_cache::redownload_album( + runtime.clone(), + weak.clone(), + handle.clone(), + id, + // Refresh the WHOLE album (Tauri's "Refresh offline copy" + // re-downloads every track, not only the failed ones). + false, + ), + ("album", "add-to-playlist") => { + // Resolve the album's loaded tracks to their Qobuz catalog + // ids and open the playlist picker for the whole set + // (mirrors Tauri's album → Add to playlist). Local/Plex + // albums carry no catalog ids, so the entry no-ops there + // (the header menu is a Qobuz surface). + let Some(w) = weak.upgrade() else { + return; + }; + let ids: Vec = { + use slint::Model; + w.global::() + .get_tracks() + .iter() + .map(|t| t.id.to_string()) + .filter(|s| s.parse::().is_ok()) + .collect() + }; + if ids.is_empty() { + toast::error(&w, "No tracks to add"); + return; + } + playlist_picker::open_multi(&w, &ids, false); + let runtime = runtime.clone(); + let weak = weak.clone(); + handle.spawn(async move { + let playlists = playlist_picker::load(&runtime).await; + let _ = weak.upgrade_in_event_loop(move |w| { + playlist_picker::apply(&w, playlists); + }); + }); + } + ("album", "share-qobuz") => { + share::copy_to_clipboard(share::qobuz_album_url(&id)); + log::info!("[qbz-slint] copied Qobuz link for album {id}"); + } + ("album", "share-songlink") => { + let source = share::qobuz_album_url(&id); + let album = id.clone(); + handle.spawn(async move { + match share::songlink_url(&source).await { + Some(url) => { + share::copy_to_clipboard(url); + log::info!("[qbz-slint] copied Album.link for album {album}"); + } + None => { + log::warn!("[qbz-slint] Album.link resolution failed for {album}") + } + } + }); + } ("track", "play-next") => { // Source-typed routing — see the ("track","queue") arm // (same seam, insert-next instead of append). @@ -5549,6 +5740,7 @@ fn main() -> Result<(), Box> { // the artist Popular Tracks, or the label Popular Tracks. if let Some(w) = weak.upgrade() { let model = match w.global::().get_view() { + ContentView::Album => w.global::().get_tracks(), ContentView::Playlist => w.global::().get_tracks(), ContentView::Label => w.global::().get_top_tracks(), ContentView::Favorites => { @@ -5571,6 +5763,8 @@ fn main() -> Result<(), Box> { } } match w.global::().get_view() { + ContentView::Album => album::recount_selected(&w), + ContentView::Artist => artist::recount_selected(&w), ContentView::Playlist => playlist::recount_selected(&w), ContentView::Favorites => favorites::recount_selected(&w), _ => {} @@ -6503,6 +6697,112 @@ fn main() -> Result<(), Box> { }); } + // Album multi-select: the toolbar toggle next to the search box. + { + let weak = window.as_weak(); + window + .global::() + .on_toggle_multi_select(move || { + if let Some(w) = weak.upgrade() { + let on = w.global::().get_multi_select(); + album::set_multi_select(&w, !on); + } + }); + } + + // Album multi-select bulk bar — actions over the selected catalog rows. + { + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + window + .global::() + .on_bulk_action(move |action| { + let Some(w) = weak.upgrade() else { + return; + }; + match action.as_str() { + "select-all" => album::select_all(&w), + "clear" => album::clear_selection(&w), + "queue" => { + let tracks = album::selected_play_tracks(&w); + if !tracks.is_empty() { + playback::enqueue_tracks( + runtime.clone(), + handle.clone(), + tracks, + false, + ); + } + } + "play-next" => { + let tracks = album::selected_play_tracks(&w); + if !tracks.is_empty() { + playback::enqueue_tracks( + runtime.clone(), + handle.clone(), + tracks, + true, + ); + } + } + "make-offline" => { + let tracks = album::selected_play_tracks(&w); + if !tracks.is_empty() { + offline_cache::cache_tracks( + runtime.clone(), + weak.clone(), + handle.clone(), + tracks, + ); + album::clear_selection(&w); + } + } + "add-to-playlist" => { + let ids = album::selected_ids(&w); + if !ids.is_empty() { + playlist_picker::open_multi(&w, &ids, false); + let runtime = runtime.clone(); + let weak = weak.clone(); + handle.spawn(async move { + let playlists = playlist_picker::load(&runtime).await; + let _ = weak.upgrade_in_event_loop(move |w| { + playlist_picker::apply(&w, playlists); + }); + }); + } + } + "add-to-favorites" => { + let ids = album::selected_ids(&w); + if ids.is_empty() { + return; + } + let runtime = runtime.clone(); + let weak = weak.clone(); + handle.spawn(async move { + for id in &ids { + match runtime.core().add_favorite("track", id).await { + Ok(()) => { + if let Ok(tid) = id.parse::() { + crate::fav_cache::set(tid, true); + } + } + Err(e) => log::error!( + "[qbz-slint] bulk favorite track {id} failed: {e}" + ), + } + } + let _ = weak.upgrade_in_event_loop(|w| { + album::clear_selection(&w); + crate::toast::success(&w, "Added to favorites"); + }); + }); + } + _ => {} + } + }); + } + // Artist in-page search — client-side filter over Popular Tracks // and every release-section album. { @@ -6516,11 +6816,158 @@ fn main() -> Result<(), Box> { }); } + // Artist Popular Tracks multi-select — the section toggle. + { + let weak = window.as_weak(); + window + .global::() + .on_toggle_top_tracks_select(move || { + if let Some(w) = weak.upgrade() { + let on = w.global::().get_top_tracks_multi_select(); + artist::set_multi_select(&w, !on); + } + }); + } + + // Artist Popular Tracks bulk bar — actions over the selected rows. + { + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + window + .global::() + .on_top_tracks_bulk_action(move |action| { + let Some(w) = weak.upgrade() else { + return; + }; + let artist_id = w.global::().get_id().to_string(); + match action.as_str() { + "select-all" => artist::select_all(&w), + "clear" => artist::clear_selection(&w), + "play-next" => playback::enqueue_artist_top_selected( + runtime.clone(), + weak.clone(), + handle.clone(), + artist_id, + artist::selected_ids(&w), + true, + ), + "queue" => playback::enqueue_artist_top_selected( + runtime.clone(), + weak.clone(), + handle.clone(), + artist_id, + artist::selected_ids(&w), + false, + ), + "add-to-playlist" => { + let ids = artist::selected_ids(&w); + if !ids.is_empty() { + playlist_picker::open_multi(&w, &ids, false); + let runtime = runtime.clone(); + let weak = weak.clone(); + handle.spawn(async move { + let playlists = playlist_picker::load(&runtime).await; + let _ = weak.upgrade_in_event_loop(move |w| { + playlist_picker::apply(&w, playlists); + }); + }); + } + } + "add-to-favorites" => { + let ids = artist::selected_ids(&w); + if ids.is_empty() { + return; + } + let runtime = runtime.clone(); + let weak = weak.clone(); + handle.spawn(async move { + for id in &ids { + match runtime.core().add_favorite("track", id).await { + Ok(()) => { + if let Ok(tid) = id.parse::() { + crate::fav_cache::set(tid, true); + } + } + Err(e) => log::error!( + "[qbz-slint] bulk favorite track {id} failed: {e}" + ), + } + } + let _ = weak.upgrade_in_event_loop(|w| { + artist::clear_selection(&w); + crate::toast::success(&w, "Added to favorites"); + }); + }); + } + _ => {} + } + }); + } + + // Artist Popular Tracks section "more" menu — all-tracks actions. + { + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + window + .global::() + .on_top_tracks_menu_action(move |action| { + let Some(w) = weak.upgrade() else { + return; + }; + let artist_id = w.global::().get_id().to_string(); + if artist_id.is_empty() { + return; + } + match action.as_str() { + "next-all" => playback::enqueue_artist_top_selected( + runtime.clone(), + weak.clone(), + handle.clone(), + artist_id, + artist::all_top_track_ids(&w), + true, + ), + "queue-all" => playback::enqueue_artist_top_selected( + runtime.clone(), + weak.clone(), + handle.clone(), + artist_id, + artist::all_top_track_ids(&w), + false, + ), + "shuffle-all" => playback::play_artist_top_shuffled( + runtime.clone(), + weak.clone(), + handle.clone(), + artist_id, + ), + "playlist-all" => { + let ids = artist::all_top_track_ids(&w); + if !ids.is_empty() { + playlist_picker::open_multi(&w, &ids, false); + let runtime = runtime.clone(); + let weak = weak.clone(); + handle.spawn(async move { + let playlists = playlist_picker::load(&runtime).await; + let _ = weak.upgrade_in_event_loop(move |w| { + playlist_picker::apply(&w, playlists); + }); + }); + } + } + _ => {} + } + }); + } + // Artist network sidebar — no persistence. Default open, user can - // close per-session, and reset_network_sidebar reopens it on every - // artist navigation. The toggle callback stays a no-op on the - // Rust side — Slint already flips NetworkSidebarState.open - // directly in the click handler. + // close per-session, and reset_network_sidebar re-applies the open + // state on every artist navigation (open unless the content area is + // space-constrained — see reset_network_sidebar). The toggle + // callback stays a no-op on the Rust side — Slint already flips + // NetworkSidebarState.open directly in the click handler. window .global::() .on_toggle(|| {}); @@ -9041,6 +9488,9 @@ fn main() -> Result<(), Box> { // Bulk add carries track-ids; single add carries track-id. let ids_model = picker.get_track_ids(); let track_id_single = picker.get_track_id().to_string(); + // Resolve the target name for the success toast BEFORE the + // model is torn down by closing the picker. + let target_name = picker_playlist_name(&w, playlist_id.as_str()); picker.set_open(false); // LOCAL playlist target (id "local:") — writes go to @@ -9058,11 +9508,16 @@ fn main() -> Result<(), Box> { if refs.is_empty() { return; } + let weak = weak.clone(); + let tname = target_name.clone(); handle.spawn(async move { - let _ = tokio::task::spawn_blocking(move || { + let added = tokio::task::spawn_blocking(move || { local_playlist::add_local_refs_blocking(&target, &refs) }) - .await; + .await + .unwrap_or(0); + // TODO(reco-v1): log playlist_add reco events here once the reco engine is extracted to a shared crate (golden-rule v1). + toast_added_tracks(&weak, added, tname); }); return; } @@ -9078,11 +9533,16 @@ fn main() -> Result<(), Box> { if ids.is_empty() { return; } + let weak = weak.clone(); + let tname = target_name.clone(); handle.spawn(async move { - let _ = tokio::task::spawn_blocking(move || { + let added = tokio::task::spawn_blocking(move || { local_playlist::add_qobuz_tracks_blocking(&target, &ids) }) - .await; + .await + .unwrap_or(0); + // TODO(reco-v1): log playlist_add reco events here once the reco engine is extracted to a shared crate (golden-rule v1). + toast_added_tracks(&weak, added, tname); }); return; } @@ -9109,10 +9569,12 @@ fn main() -> Result<(), Box> { // sidebar's session cache; re-adding an existing ref // MOVES it to the append slot (INSERT OR REPLACE, E4). let qobuz_count = sidebar::playlist_track_count(pid).unwrap_or(0); + let refs_count = refs.len(); let runtime = runtime.clone(); let weak = weak.clone(); let handle2 = handle.clone(); let image_cache = image_cache.clone(); + let tname = target_name.clone(); handle.spawn(async move { let _ = tokio::task::spawn_blocking(move || { crate::library_db::with_db(|db| { @@ -9131,6 +9593,8 @@ fn main() -> Result<(), Box> { }) }) .await; + // TODO(reco-v1): log playlist_add reco events here once the reco engine is extracted to a shared crate (golden-rule v1). + toast_added_tracks(&weak, refs_count, tname); // E12: the open detail re-merges so the rows show // up immediately. let _ = weak.clone().upgrade_in_event_loop(move |w| { @@ -9151,7 +9615,12 @@ fn main() -> Result<(), Box> { return; } - // Qobuz path. + // Qobuz tracks → Qobuz playlist. Run duplicate detection first + // (Tauri parity: this is the ONLY branch that checks dupes). + // If any of the ids are already in the target, park the context + // in DUP_CONFIRM_STASH and open the confirm sub-modal; the user + // then chooses add-all / add-new-only. With no dupes we add + // directly and toast. let mut ids: Vec = (0..ids_model.row_count()) .filter_map(|i| ids_model.row_data(i)) .filter_map(|s| s.parse::().ok()) @@ -9165,9 +9634,55 @@ fn main() -> Result<(), Box> { return; } let runtime = runtime.clone(); + let weak = weak.clone(); + let tname = target_name.clone(); handle.spawn(async move { - if let Err(e) = runtime.core().add_tracks_to_playlist(pid, &ids).await { - log::error!("[qbz-slint] add to playlist failed: {e}"); + match runtime.core().check_playlist_duplicates(pid, &ids).await { + Ok(dup) if dup.duplicate_count > 0 => { + // Stash the full context; the confirm handlers read + // it back. Open the sub-modal with the counts. + let total = dup.total_tracks as i32; + let dupc = dup.duplicate_count as i32; + let dup_ids = dup.duplicate_track_ids.clone(); + let stash = (pid, ids.clone(), dup_ids, tname.clone()); + let _ = weak.upgrade_in_event_loop(move |w| { + DUP_CONFIRM_STASH.with(|c| *c.borrow_mut() = Some(stash)); + let st = w.global::(); + st.set_duplicate_count(dupc); + st.set_total_count(total); + st.set_busy(false); + st.set_playlist_name(tname.into()); + st.set_open(true); + }); + } + Ok(_) => { + // No duplicates — add directly + toast. + let n = ids.len(); + if let Err(e) = + runtime.core().add_tracks_to_playlist(pid, &ids).await + { + log::error!("[qbz-slint] add to playlist failed: {e}"); + } else { + // TODO(reco-v1): log playlist_add reco events here once the reco engine is extracted to a shared crate (golden-rule v1). + toast_added_tracks(&weak, n, tname); + } + } + Err(e) => { + // Dup check failed (e.g. transient API) — fall back + // to a direct add so the action still completes. + log::warn!( + "[qbz-slint] dup check failed, adding directly: {e}" + ); + let n = ids.len(); + if let Err(e) = + runtime.core().add_tracks_to_playlist(pid, &ids).await + { + log::error!("[qbz-slint] add to playlist failed: {e}"); + } else { + // TODO(reco-v1): log playlist_add reco events here once the reco engine is extracted to a shared crate (golden-rule v1). + toast_added_tracks(&weak, n, tname); + } + } } }); }); @@ -9178,7 +9693,291 @@ fn main() -> Result<(), Box> { .global::() .on_close(move || { if let Some(w) = weak.upgrade() { - w.global::().set_open(false); + let st = w.global::(); + st.set_open(false); + // Reset the inline-create + filter affordances so the next + // open starts clean. + st.set_creating_open(false); + st.set_create_name("".into()); + st.set_creating(false); + st.set_filter("".into()); + } + }); + } + + // Inline "Create new playlist" → create-and-add. Creates a playlist + // (Qobuz online / local offline per D8) and adds the carried tracks to + // it, then closes the picker. Discriminates the carried ids exactly like + // the pick handler (local-mode refs vs Qobuz u64 ids). + { + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + window + .global::() + .on_create_and_add(move || { + let Some(w) = weak.upgrade() else { + return; + }; + use slint::Model; + let picker = w.global::(); + let name = picker.get_create_name().to_string(); + if name.trim().is_empty() || picker.get_creating() { + return; + } + let is_local = picker.get_local_mode(); + let ids_model = picker.get_track_ids(); + let track_id_single = picker.get_track_id().to_string(); + // Local-mode refs (LocalLibrary row ids / "plex:") for the + // local-playlist add; Qobuz u64 ids for the online path. + let refs: Vec = (0..ids_model.row_count()) + .filter_map(|i| ids_model.row_data(i)) + .map(|s| s.to_string()) + .collect(); + let mut qobuz_ids: Vec = (0..ids_model.row_count()) + .filter_map(|i| ids_model.row_data(i)) + .filter_map(|s| s.parse::().ok()) + .collect(); + if qobuz_ids.is_empty() { + if let Ok(tid) = track_id_single.parse::() { + qobuz_ids.push(tid); + } + } + picker.set_creating(true); + + let offline_now = offline_mode::engine().is_offline(); + let nm = name.trim().to_string(); + let runtime = runtime.clone(); + let weak = weak.clone(); + let handle2 = handle.clone(); + + if offline_now { + // D8: offline ⇒ LOCAL playlist (library.db), never the + // retired pending-playlist engine. Mirrors the create + // modal's offline branch. + let local_refs = refs.clone(); + let local_qobuz = qobuz_ids.clone(); + handle.spawn(async move { + let created = tokio::task::spawn_blocking({ + let nm = nm.clone(); + move || local_playlist::create_blocking(&nm, None, true) + }) + .await + .ok() + .flatten(); + let mut added = 0usize; + if let Some(ref new_id) = created { + let new_id = new_id.clone(); + added = tokio::task::spawn_blocking(move || { + if is_local { + local_playlist::add_local_refs_blocking( + &new_id, + &local_refs, + ) + } else { + local_playlist::add_qobuz_tracks_blocking( + &new_id, + &local_qobuz, + ) + } + }) + .await + .unwrap_or(0); + } + // TODO(reco-v1): log playlist_add reco events here once the reco engine is extracted to a shared crate (golden-rule v1). + let r2 = runtime.clone(); + let h2 = handle2.clone(); + let weak2 = weak.clone(); + let nm2 = nm.clone(); + let _ = weak.upgrade_in_event_loop(move |w| { + let st = w.global::(); + st.set_creating(false); + st.set_creating_open(false); + st.set_create_name("".into()); + st.set_open(false); + match created { + Some(_) => { + toast_added_tracks(&weak2, added, nm2); + load_sidebar_playlists(r2, weak2, &h2); + } + None => { + log::error!( + "[qbz-slint] create-and-add (local) failed" + ); + } + } + }); + }); + return; + } + + // Online ⇒ Qobuz playlist, then add the carried tracks. + handle.spawn(async move { + match runtime.core().create_playlist(&nm, None, false).await { + Ok(playlist) => { + let pid = playlist.id; + let n = qobuz_ids.len(); + if !qobuz_ids.is_empty() { + if let Err(e) = runtime + .core() + .add_tracks_to_playlist(pid, &qobuz_ids) + .await + { + log::error!( + "[qbz-slint] create-and-add: add failed: {e}" + ); + } + } + // TODO(reco-v1): log playlist_add reco events here once the reco engine is extracted to a shared crate (golden-rule v1). + let r2 = runtime.clone(); + let h2 = handle2.clone(); + let weak2 = weak.clone(); + let nm2 = nm.clone(); + let _ = weak.upgrade_in_event_loop(move |w| { + let st = w.global::(); + st.set_creating(false); + st.set_creating_open(false); + st.set_create_name("".into()); + st.set_open(false); + toast_added_tracks(&weak2, n, nm2); + load_sidebar_playlists(r2, weak2, &h2); + }); + } + Err(e) => { + log::error!("[qbz-slint] create-and-add: create failed: {e}"); + let _ = weak.upgrade_in_event_loop(|w| { + w.global::().set_creating(false); + }); + } + } + }); + }); + } + + // Picker client-side filter — recompute each row's `filter-rank` + // (case-insensitive substring; Slint 1.16 has no string `.contains`, so + // the match runs here). Rank = match ordinal among the filtered rows, + // -1 = filtered out; the total lands in `filter-matches`. Pure frontend, + // no backend call. + { + let weak = window.as_weak(); + window + .global::() + .on_filter_changed(move |query| { + let Some(w) = weak.upgrade() else { + return; + }; + use slint::Model; + let needle = query.to_lowercase(); + let model = w.global::().get_playlists(); + let mut rank: i32 = 0; + for i in 0..model.row_count() { + if let Some(mut item) = model.row_data(i) { + let matches = needle.is_empty() + || item.name.to_lowercase().contains(&needle); + let new_rank = if matches { rank } else { -1 }; + if matches { + rank += 1; + } + if item.filter_rank != new_rank { + item.filter_rank = new_rank; + model.set_row_data(i, item); + } + } + } + w.global::().set_filter_matches(rank); + }); + } + + // Duplicate-confirm sub-modal handlers. The pending context lives in + // DUP_CONFIRM_STASH (set by the picker's Qobuz→Qobuz branch). Each handler + // reads it, performs the chosen add, toasts, then closes + clears. + { + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + window + .global::() + .on_add_all(move || { + let Some(stash) = DUP_CONFIRM_STASH.with(|c| c.borrow_mut().take()) else { + return; + }; + let (pid, all_ids, _dup_ids, name) = stash; + if let Some(w) = weak.upgrade() { + w.global::().set_busy(true); + } + let runtime = runtime.clone(); + let weak = weak.clone(); + handle.spawn(async move { + let n = all_ids.len(); + if let Err(e) = runtime.core().add_tracks_to_playlist(pid, &all_ids).await + { + log::error!("[qbz-slint] dup add-all failed: {e}"); + } else { + // TODO(reco-v1): log playlist_add reco events here once the reco engine is extracted to a shared crate (golden-rule v1). + toast_added_tracks(&weak, n, name); + } + let _ = weak.upgrade_in_event_loop(|w| { + let st = w.global::(); + st.set_busy(false); + st.set_open(false); + }); + }); + }); + } + { + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + window + .global::() + .on_add_new_only(move || { + let Some(stash) = DUP_CONFIRM_STASH.with(|c| c.borrow_mut().take()) else { + return; + }; + let (pid, all_ids, dup_ids, name) = stash; + // Add only the non-duplicate ids (all \ duplicates). If nothing + // is left, just close. + let to_add: Vec = + all_ids.into_iter().filter(|id| !dup_ids.contains(id)).collect(); + if to_add.is_empty() { + if let Some(w) = weak.upgrade() { + w.global::().set_open(false); + } + return; + } + if let Some(w) = weak.upgrade() { + w.global::().set_busy(true); + } + let runtime = runtime.clone(); + let weak = weak.clone(); + handle.spawn(async move { + let n = to_add.len(); + if let Err(e) = runtime.core().add_tracks_to_playlist(pid, &to_add).await + { + log::error!("[qbz-slint] dup add-new-only failed: {e}"); + } else { + // TODO(reco-v1): log playlist_add reco events here once the reco engine is extracted to a shared crate (golden-rule v1). + toast_added_tracks(&weak, n, name); + } + let _ = weak.upgrade_in_event_loop(|w| { + let st = w.global::(); + st.set_busy(false); + st.set_open(false); + }); + }); + }); + } + { + let weak = window.as_weak(); + window + .global::() + .on_cancel(move || { + DUP_CONFIRM_STASH.with(|c| *c.borrow_mut() = None); + if let Some(w) = weak.upgrade() { + let st = w.global::(); + st.set_busy(false); + st.set_open(false); } }); } @@ -9774,6 +10573,18 @@ fn main() -> Result<(), Box> { } }); } + { + // Import playlist — open the importer modal fully reset, with the + // folder dropdown rebuilt from the current sidebar folder list. + let weak = window.as_weak(); + window + .global::() + .on_import_playlist(move || { + if let Some(w) = weak.upgrade() { + playlist_import::open(&w); + } + }); + } { // Edit playlist (sidebar context menu) — open the edit-playlist // modal, prefilled from the cached name + description. @@ -10047,6 +10858,175 @@ fn main() -> Result<(), Box> { }); } + // ---- Playlist Importer (public playlists) — spec §3.3 ---- + { + // No cancel exists: a running import task continues to completion + // (§1.8); closing only hides the modal. + let weak = window.as_weak(); + window.global::().on_close(move || { + if let Some(w) = weak.upgrade() { + w.global::().set_open(false); + } + }); + } + { + // Provider detection per keystroke (Slint 1.16 has no `.contains`). + let weak = window.as_weak(); + window + .global::() + .on_url_edited(move |text| { + if let Some(w) = weak.upgrade() { + playlist_import::on_url_edited(&w, text.as_str()); + } + }); + } + { + window + .global::() + .on_name_edited(move |text| { + playlist_import::on_name_edited(text.as_str()); + }); + } + { + // Step A: fetch the preview (no session needed). + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + window.global::().on_fetch(move || { + let Some(w) = weak.upgrade() else { return; }; + let Some(url) = playlist_import::begin_fetch(&w) else { + return; + }; + // A reopen mid-fetch bumps the generation; the stale preview + // result must not land on the fresh modal (§1.8). + let generation = playlist_import::current_generation(); + let weak = weak.clone(); + handle.spawn(async move { + let res = qbz_playlist_import::preview_public_playlist(&url).await; + let _ = weak.upgrade_in_event_loop(move |w| { + if generation != playlist_import::current_generation() { + return; + } + match res { + Ok(p) => playlist_import::apply_preview_ok(&w, &url, p), + Err(e) => playlist_import::apply_preview_err(&w, &e.to_string()), + } + }); + }); + }); + } + { + // Step B: execute the import (re-fetches the URL internally — + // Tauri behavior, kept) with live sink progress. + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window + .global::() + .on_execute(move || { + let Some(w) = weak.upgrade() else { return; }; + let Some(args) = playlist_import::begin_execute(&w) else { + return; + }; + let runtime = runtime.clone(); + let weak = weak.clone(); + let handle = handle.clone(); + let image_cache = image_cache.clone(); + handle.clone().spawn(async move { + // Tauri's RequiresUserSession gate: execute needs a + // logged-in client (the preview does not). + let client = runtime.core().client().read().await.clone(); + let Some(client) = client else { + let g = args.generation; + let _ = weak.upgrade_in_event_loop(move |w| { + if g == playlist_import::current_generation() { + playlist_import::apply_execute_err( + &w, + "Not logged in to Qobuz", + ); + } + toast::show(&w, "Playlist import failed", ToastKind::Error); + }); + return; + }; + let sink: Arc = Arc::new( + playlist_import::SlintSink::new(weak.clone(), args.generation), + ); + let res = qbz_playlist_import::import_public_playlist( + &args.url, + &client, + args.name_override.as_deref(), + false, // is_public — Tauri hardcodes false, no toggle + sink, + ) + .await; + match res { + Ok(summary) => { + // TODO(reco-v1): log playlist_add reco events here once the reco engine is extracted to a shared crate (golden-rule v1). + // NOTE: Tauri's importer never logged reco — adding it here is parity-plus, one event per matched track on import success. + // Assign every created part to the chosen folder + // (local DB) BEFORE the sidebar reload — mirrors + // the create-playlist precedent above. + if !args.folder_id.is_empty() { + for pid in &summary.qobuz_playlist_ids { + let (pid, fid) = (*pid, args.folder_id.clone()); + tokio::task::spawn_blocking(move || { + folders::move_playlist(pid, Some(fid.as_str())); + }) + .await + .ok(); + } + } + let g = args.generation; + let weak2 = weak.clone(); + let r2 = runtime.clone(); + let h2 = handle.clone(); + let _ = weak.upgrade_in_event_loop(move |w| { + // Toast + sidebar refresh fire even after a + // close mid-import (§1.8); the generation + // guard keeps a stale run's writes off a + // reopened modal's fresh state. + if g == playlist_import::current_generation() { + playlist_import::apply_execute_ok(&w, &summary); + } + if summary.matched_tracks > 0 { + toast::show(&w, "Playlist imported", ToastKind::Success); + } + load_sidebar_playlists(r2.clone(), weak2.clone(), &h2); + if let Some(first) = summary.qobuz_playlist_ids.first() { + // Navigate only while the modal is still + // open AND this run is current (§1.8). + if g == playlist_import::current_generation() + && w.global::().get_open() + { + nav::record(nav::NavEntry::Playlist(first.to_string())); + navigate_playlist( + r2, + weak2, + &h2, + image_cache, + first.to_string(), + ); + update_nav_flags(&w); + } + } + }); + } + Err(e) => { + let g = args.generation; + let msg = e.to_string(); + let _ = weak.upgrade_in_event_loop(move |w| { + if g == playlist_import::current_generation() { + playlist_import::apply_execute_err(&w, &msg); + } + toast::show(&w, "Playlist import failed", ToastKind::Error); + }); + } + } + }); + }); + } + // handler. { let runtime = app_runtime.clone(); @@ -10530,6 +11510,9 @@ fn main() -> Result<(), Box> { return; }; favorites::mark_album_removing(&w, &id); + // Keep the favorite-album cache in sync so the album-header + // heart reflects an unfavorite done from the Favorites view. + crate::fav_cache::set_album(&id, false); let id_srv = id.to_string(); let runtime = runtime.clone(); handle.spawn(async move { diff --git a/crates/qbz-slint/src/playlist_import.rs b/crates/qbz-slint/src/playlist_import.rs new file mode 100644 index 000000000..b1705967a --- /dev/null +++ b/crates/qbz-slint/src/playlist_import.rs @@ -0,0 +1,486 @@ +//! Playlist Importer controller — the Rust side of the "Import Playlist" +//! modal (PlaylistImportModal.slint), a 1:1 port of Tauri's +//! PlaylistImportModal.svelte driven by the headless `qbz-playlist-import` +//! crate. Every interpolated string (log lines, status line, summary +//! block) is formatted HERE and pushed into PlaylistImportState +//! pre-formatted; provider detection lives here too (Slint 1.16 strings +//! have no `.contains`, so every URL keystroke round-trips through +//! `url-edited`). +//! +//! Close-mid-import semantics (spec §1.8): closing the modal never cancels +//! the tokio import task. On completion the toast + sidebar refresh still +//! fire (main.rs arm); navigation happens only while the modal is still +//! open AND the run's generation is current. [`GENERATION`] is bumped on +//! every open() and execute(), so a stale run's sink events / completion +//! can never touch a reopened modal's fresh state. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{LazyLock, Mutex}; + +use slint::{ComponentHandle, Model, ModelRc, VecModel}; + +use qbz_playlist_import::{ + detect_provider_key, ImportEvent, ImportPhase, ImportPlaylist, ImportProgressSink, + ImportProvider, ImportSummary, ProviderKey, +}; + +use crate::{AppWindow, ImportLogEntry, PlaylistImportState, SidebarState}; + +/// Rust-side mirror of the Svelte component state that never reaches the +/// UI (sidebar.rs module-state pattern). Reset wholesale on every open. +#[derive(Default)] +struct Session { + preview: Option, + /// Trimmed URL the preview was fetched for (Svelte `previewUrl`). + preview_url: String, + /// Provider locked at fetch time; survives URL edits until the reset + /// paths clear it (Svelte `lockedProvider`). + locked_provider: Option, + /// Trimmed URL of the last completed import (Svelte `lastImportedUrl`). + last_imported_url: String, + /// 5%-milestone tracker for the matching log lines (-1 = none yet). + last_logged_percent: i32, + /// Mirror of the modal's rename field, kept fresh by `name-edited` + /// and read at execute time (Svelte `customName`). + custom_name: String, +} + +static SESSION: LazyLock> = LazyLock::new(|| Mutex::new(Session::default())); + +/// Import generation (§1.8): bumped on every open() and execute(). Sink +/// events and task completions carry the generation they were spawned +/// with; a mismatch means the modal was reset for a fresh run, so the +/// stale run may only fire toast + sidebar refresh, never modal writes. +static GENERATION: AtomicU64 = AtomicU64::new(0); + +pub fn current_generation() -> u64 { + GENERATION.load(Ordering::SeqCst) +} + +fn bump_generation() -> u64 { + GENERATION.fetch_add(1, Ordering::SeqCst) + 1 +} + +/// Open the modal fully reset — Tauri remounts the Svelte component on +/// every open, so nothing persists. Event-loop thread. +pub fn open(window: &AppWindow) { + // Invalidate any in-flight run's modal writes before resetting. + bump_generation(); + *SESSION.lock().unwrap() = Session::default(); + + let state = window.global::(); + state.set_url("".into()); + state.set_custom_name("".into()); + state.set_loading(false); + state.set_error("".into()); + state.set_active_provider("".into()); + state.set_can_fetch(false); + state.set_show_preview(false); + state.set_import_completed(false); + state.set_progress_visible(false); + state.set_has_progress(false); + state.set_progress(0.0); + state.set_status_line("".into()); + state.set_current_track("".into()); + state.set_log(ModelRc::new(VecModel::from(Vec::::new()))); + clear_summary(window); + + // Folder dropdown from the sidebar's folder list — the exact + // create-playlist builder pattern: index 0 = "No folder" (id ""). + let folders = window.global::().get_folders(); + let mut opts: Vec = vec!["No folder".into()]; + let mut ids: Vec = vec!["".into()]; + for i in 0..folders.row_count() { + if let Some(f) = folders.row_data(i) { + opts.push(f.name); + ids.push(f.id); + } + } + state.set_folder_options(ModelRc::new(VecModel::from(opts))); + state.set_folder_ids(ModelRc::new(VecModel::from(ids))); + state.set_folder_index(0); + + state.set_open(true); +} + +/// Recompute the URL-derived properties on every keystroke (Svelte's +/// derived detectedProvider / activeProvider / isValid / showPreview), +/// plus the post-completion fresh-import reset path. Event-loop thread. +pub fn on_url_edited(window: &AppWindow, text: &str) { + let state = window.global::(); + let trimmed = text.trim(); + let detected = detect_provider_key(text); + + let mut s = SESSION.lock().unwrap(); + + // After a completed import, editing the URL away from the imported + // one rearms the modal for a fresh import without reopening. + if state.get_import_completed() && trimmed != s.last_imported_url { + s.locked_provider = None; + state.set_import_completed(false); + state.set_error("".into()); + state.set_log(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_progress_visible(false); + state.set_has_progress(false); + state.set_progress(0.0); + state.set_status_line("".into()); + state.set_current_track("".into()); + clear_summary(window); + } + + let active = s.locked_provider.or(detected); + state.set_active_provider(active.map(|p| p.as_str()).unwrap_or("").into()); + state.set_can_fetch(detected.is_some() && !crate::offline_mode::engine().is_offline()); + state.set_show_preview(s.preview.is_some() && trimmed == s.preview_url); +} + +/// Keep the session's rename mirror fresh (read back by +/// [`begin_execute`]). Fired on every name-LineEdit keystroke. +pub fn on_name_edited(text: &str) { + SESSION.lock().unwrap().custom_name = text.to_string(); +} + +/// Step A gate + reset (Svelte handlePreview's pre-invoke block). Returns +/// the URL to fetch, or None when the gate fails. Event-loop thread. +pub fn begin_fetch(window: &AppWindow) -> Option { + let state = window.global::(); + if state.get_loading() || !state.get_can_fetch() { + return None; + } + let url = state.get_url().to_string(); + let detected = detect_provider_key(&url)?; + { + let mut s = SESSION.lock().unwrap(); + s.preview = None; + s.preview_url.clear(); + s.locked_provider = Some(detected); + } + state.set_loading(true); + state.set_error("".into()); + state.set_show_preview(false); + state.set_import_completed(false); + state.set_active_provider(detected.as_str().into()); + state.set_has_progress(false); + state.set_progress(0.0); + state.set_status_line("".into()); + state.set_current_track("".into()); + clear_summary(window); + state.set_log(ModelRc::new(VecModel::from(Vec::::new()))); + push_log(window, "Checking playlist link...".to_string(), "info"); + state.set_progress_visible(true); + Some(url) +} + +/// Preview fetch succeeded (Svelte handlePreview's try arm). Event-loop. +pub fn apply_preview_ok(window: &AppWindow, url: &str, preview: ImportPlaylist) { + let state = window.global::(); + let count = preview.tracks.len(); + let provider = provider_display_name(&preview.provider); + state.set_custom_name(preview.name.as_str().into()); + { + let mut s = SESSION.lock().unwrap(); + s.custom_name = preview.name.clone(); + s.preview_url = url.trim().to_string(); + s.preview = Some(preview); + } + push_log( + window, + format!("Found {} tracks from {}.", count, provider), + "success", + ); + state.set_loading(false); + // The URL input is disabled during the fetch, so it still equals the + // fetched URL — step B (rename + Import) becomes visible. + state.set_show_preview( + state.get_url().trim() == SESSION.lock().unwrap().preview_url.as_str(), + ); +} + +/// Preview fetch failed (Svelte handlePreview's catch arm). Event-loop. +pub fn apply_preview_err(window: &AppWindow, err: &str) { + let state = window.global::(); + state.set_error(err.into()); + push_log(window, format!("Import failed: {}", err), "error"); + state.set_loading(false); +} + +/// Everything the execute task needs, snapshotted on the event loop. +pub struct ExecuteArgs { + pub url: String, + pub name_override: Option, + /// Local folder id chosen in the dropdown ("" = no folder). + pub folder_id: String, + /// The run's generation (§1.8), carried by the sink and the + /// completion arms. + pub generation: u64, +} + +/// Step B gate + reset (Svelte handleExecute's pre-invoke block). +/// Event-loop thread. +pub fn begin_execute(window: &AppWindow) -> Option { + let state = window.global::(); + if state.get_loading() || state.get_import_completed() { + return None; + } + let (url, name_override) = { + let mut s = SESSION.lock().unwrap(); + let source_name = s.preview.as_ref()?.name.clone(); + // Rename goes out only when it differs from the source name; an + // empty rename falls back to the source name (Appendix A). + let custom = s.custom_name.trim().to_string(); + let name_override = if custom != source_name && !custom.is_empty() { + Some(custom) + } else { + None + }; + s.last_logged_percent = -1; + (s.preview_url.clone(), name_override) + }; + let folder_id = { + let ids = state.get_folder_ids(); + ids.row_data(state.get_folder_index() as usize) + .map(|s| s.to_string()) + .unwrap_or_default() + }; + state.set_loading(true); + state.set_error("".into()); + state.set_progress_visible(true); + Some(ExecuteArgs { + url, + name_override, + folder_id, + generation: bump_generation(), + }) +} + +/// One sink event onto the modal — the two Svelte event listeners. The +/// generation is checked by the caller (SlintSink). Event-loop thread. +pub fn apply_event(window: &AppWindow, event: ImportEvent) { + let state = window.global::(); + match event { + ImportEvent::Phase(phase) => match phase { + ImportPhase::Matching => { + push_log(window, "Searching Qobuz catalog...".to_string(), "info"); + } + // Creating / Adding re-fire once per created part — log each, + // as Tauri does. + ImportPhase::Creating => { + push_log(window, "Creating playlist...".to_string(), "success"); + } + ImportPhase::Adding => { + push_log(window, "Adding tracks to playlist...".to_string(), "info"); + } + }, + ImportEvent::Progress(p) => { + // Bar + status update on EVERY event (Tauri parity, no + // coalescing). + state.set_has_progress(p.total > 0); + if p.total > 0 { + state.set_progress(p.current as f32 / p.total as f32); + } + if p.phase == "adding" { + // Status line per phase — deliberate owner deviation from + // the Tauri modal, which reused the "Matching tracks…" + // string here (see qbz_playlist_import::sink::ImportPhase). + let line = format!("Adding tracks: {} / {}", p.current, p.total); + state.set_status_line(line.as_str().into()); + // One log line per 50-track chunk event (chunk counts, + // not tracks) — Tauri logs every adding event. + push_log(window, line, "info"); + } else if p.total > 0 { + let line = format!( + "Matching tracks: {} / {} ({} found)", + group_thousands(p.current), + group_thousands(p.total), + group_thousands(p.matched_so_far) + ); + state.set_status_line(line.as_str().into()); + // Matching is high-frequency (one event per track): log + // only at 5% milestones, exactly like the Svelte listener. + let pct = (p.current as u64 * 100 / p.total as u64) as i32; + let should_log = { + let mut s = SESSION.lock().unwrap(); + if pct >= s.last_logged_percent + 5 { + s.last_logged_percent = pct; + true + } else { + false + } + }; + if should_log { + push_log(window, line, "info"); + } + } + state.set_current_track(p.current_track.unwrap_or_default().into()); + } + } +} + +/// Import finished (Svelte handleExecute's success arm): completion logs +/// + summary block + import-completed. Toast / sidebar refresh / +/// navigation live in the main.rs arm (§1.8: those fire even after +/// close; this fn is generation-guarded by the caller). Event-loop. +pub fn apply_execute_ok(window: &AppWindow, summary: &ImportSummary) { + let state = window.global::(); + { + let mut s = SESSION.lock().unwrap(); + s.last_imported_url = s.preview_url.clone(); + } + state.set_import_completed(true); + push_log( + window, + format!( + "Imported {} of {} tracks into QBZ.", + summary.matched_tracks, summary.total_tracks + ), + "success", + ); + if !summary.qobuz_playlist_ids.is_empty() { + if summary.parts_created > 1 { + push_log(window, parts_line(summary.parts_created), "success"); + } else { + push_log(window, "Playlist created in Qobuz™.".to_string(), "success"); + } + } else { + push_log(window, "No matching tracks found.".to_string(), "error"); + } + // Summary block (pre-formatted; "" = hidden). `playlist_name` is the + // name the playlist was created under — rename included (deliberate + // owner fix vs the Tauri original, see qbz_playlist_import::importer). + state.set_summary_playlist(format!("Playlist: {}", summary.playlist_name).into()); + state.set_summary_matched( + format!( + "Tracks matched: {} / {}", + summary.matched_tracks, summary.total_tracks + ) + .into(), + ); + state.set_summary_skipped(format!("Skipped: {}", summary.skipped_tracks).into()); + state.set_summary_parts(if summary.parts_created > 1 { + parts_line(summary.parts_created).into() + } else { + "".into() + }); + // The bar/status hide with loading, as in Tauri (`loading` gates the + // bar there). + state.set_has_progress(false); + state.set_status_line("".into()); + state.set_current_track("".into()); + state.set_loading(false); +} + +/// Import failed (Svelte handleExecute's catch arm). The error toast +/// lives in the main.rs arm. Event-loop thread. +pub fn apply_execute_err(window: &AppWindow, err: &str) { + let state = window.global::(); + state.set_error(err.into()); + push_log(window, format!("Import failed: {}", err), "error"); + state.set_has_progress(false); + state.set_status_line("".into()); + state.set_current_track("".into()); + state.set_loading(false); +} + +/// Streams crate events onto the modal via the established +/// `upgrade_in_event_loop` cross-thread hop — one hop per event, the same +/// frequency profile as the artwork/scan pipelines (Tauri also updated +/// the bar per event, no coalescing). +pub struct SlintSink { + weak: slint::Weak, + generation: u64, +} + +impl SlintSink { + pub fn new(weak: slint::Weak, generation: u64) -> Self { + Self { weak, generation } + } +} + +impl ImportProgressSink for SlintSink { + fn emit(&self, event: ImportEvent) { + let generation = self.generation; + let _ = self.weak.upgrade_in_event_loop(move |w| { + // Stale generation = the modal was reset (reopened) while + // this run was in flight — its events must never touch the + // fresh modal state (§1.8). + if generation == current_generation() { + apply_event(&w, event); + } + }); + } +} + +/// Append one pre-formatted line to the conversion log (append-only +/// VecModel). Event-loop thread. +fn push_log(window: &AppWindow, message: String, status: &str) { + let state = window.global::(); + let log = state.get_log(); + let entry = ImportLogEntry { + message: message.into(), + status: status.into(), + }; + if let Some(vec) = log.as_any().downcast_ref::>() { + vec.push(entry); + } else { + // First write after the .slint default literal — swap in a + // VecModel (open()/begin_fetch() normally install one already). + let mut entries: Vec = log.iter().collect(); + entries.push(entry); + state.set_log(ModelRc::new(VecModel::from(entries))); + } +} + +fn clear_summary(window: &AppWindow) { + let state = window.global::(); + state.set_summary_playlist("".into()); + state.set_summary_matched("".into()); + state.set_summary_skipped("".into()); + state.set_summary_parts("".into()); +} + +/// "Split into {count} playlists (Qobuz 2000-track limit)" — used as both +/// a log line and the summary parts line, as in Tauri. +fn parts_line(count: u32) -> String { + format!("Split into {} playlists (Qobuz 2000-track limit)", count) +} + +/// Display names for the "Found N tracks from {provider}." log (Svelte +/// formatProvider). The enum is exhaustive, so Svelte's "Unknown" arm is +/// unreachable here. +fn provider_display_name(provider: &ImportProvider) -> &'static str { + match provider { + ImportProvider::Spotify => "Spotify", + ImportProvider::AppleMusic => "Apple Music", + ImportProvider::Tidal => "Tidal", + ImportProvider::Deezer => "Deezer", + } +} + +/// `toLocaleString()` twin for the matching log/status numbers +/// ("12,345"). Tauri rendered these with the user's locale; fixed en-US +/// grouping is the deliberate choice here. +fn group_thousands(n: u32) -> String { + let digits = n.to_string(); + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + for (i, ch) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i) % 3 == 0 { + out.push(','); + } + out.push(ch); + } + out +} + +#[cfg(test)] +mod tests { + use super::group_thousands; + + #[test] + fn group_thousands_matches_to_locale_string() { + assert_eq!(group_thousands(0), "0"); + assert_eq!(group_thousands(999), "999"); + assert_eq!(group_thousands(1000), "1,000"); + assert_eq!(group_thousands(12345), "12,345"); + assert_eq!(group_thousands(1234567), "1,234,567"); + } +} diff --git a/crates/qbz-slint/src/playlist_picker.rs b/crates/qbz-slint/src/playlist_picker.rs index f68c68987..b57149306 100644 --- a/crates/qbz-slint/src/playlist_picker.rs +++ b/crates/qbz-slint/src/playlist_picker.rs @@ -23,6 +23,7 @@ pub fn open(window: &AppWindow, track_id: &str) { state.set_track_id(track_id.into()); state.set_track_ids(ModelRc::new(VecModel::from(Vec::::new()))); state.set_playlists(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_filter_matches(0); state.set_local_mode(false); state.set_loading(true); state.set_open(true); @@ -39,6 +40,7 @@ pub fn open_multi(window: &AppWindow, ids: &[String], local: bool) { let model: Vec = ids.iter().map(|s| s.clone().into()).collect(); state.set_track_ids(ModelRc::new(VecModel::from(model))); state.set_playlists(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_filter_matches(0); state.set_local_mode(local); state.set_loading(true); state.set_open(true); @@ -88,7 +90,8 @@ where pub fn apply(window: &AppWindow, playlists: Vec) { let items: Vec = playlists .into_iter() - .map(|p| PlaylistPickItem { + .enumerate() + .map(|(i, p)| PlaylistPickItem { id: p.id.into(), name: p.name.into(), tracks_line: if p.tracks > 0 { @@ -97,9 +100,15 @@ pub fn apply(window: &AppWindow, playlists: Vec) { "".into() }, is_local: p.is_local, + // No filter yet on (re)load — every row matches, ranked in + // list order. + filter_rank: i as i32, }) .collect(); let state = window.global::(); + state.set_filter_matches(items.len() as i32); state.set_playlists(ModelRc::new(VecModel::from(items))); + // Reset the filter affordance whenever the list is repopulated. + state.set_filter("".into()); state.set_loading(false); } diff --git a/crates/qbz-slint/ui/app.slint b/crates/qbz-slint/ui/app.slint index 287a7df0f..01fe403f1 100644 --- a/crates/qbz-slint/ui/app.slint +++ b/crates/qbz-slint/ui/app.slint @@ -16,9 +16,10 @@ import { SplashScreen } from "splash/SplashScreen.slint"; import { LoginScreen } from "login/LoginScreen.slint"; import { AppShell } from "shell/AppShell.slint"; import { Toast } from "primitives/Toast.slint"; +import { ShellState } from "state.slint"; // Re-export the state globals so the Rust layer can populate them. -export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, OfflineState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions } from "state.slint"; +export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, OfflineState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions } from "state.slint"; // Which top-level screen is shown. The app starts on `splash` while it // restores a saved session, then resolves to `shell` or `login`. @@ -37,6 +38,15 @@ export component AppWindow inherits Window { in property system-font: ""; default-font-family: root.system-font; + // Publish the live window width so content-area views can make + // responsiveness decisions (see ShellState.content-constrained). + init => { + ShellState.window-width = self.width; + } + changed width => { + ShellState.window-width = self.width; + } + in-out property screen: AppScreen.splash; // Forwarded to the Rust layer. diff --git a/crates/qbz-slint/ui/assets/icons/apple-music-logo.svg b/crates/qbz-slint/ui/assets/icons/apple-music-logo.svg new file mode 100644 index 000000000..f4f9aceea --- /dev/null +++ b/crates/qbz-slint/ui/assets/icons/apple-music-logo.svg @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/crates/qbz-slint/ui/assets/icons/deezer-logo.svg b/crates/qbz-slint/ui/assets/icons/deezer-logo.svg new file mode 100644 index 000000000..d6293c390 --- /dev/null +++ b/crates/qbz-slint/ui/assets/icons/deezer-logo.svg @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/crates/qbz-slint/ui/assets/icons/import.svg b/crates/qbz-slint/ui/assets/icons/import.svg new file mode 100644 index 000000000..15d9616ec --- /dev/null +++ b/crates/qbz-slint/ui/assets/icons/import.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-slint/ui/assets/icons/qobuz-logo.svg b/crates/qbz-slint/ui/assets/icons/qobuz-logo.svg new file mode 100644 index 000000000..01494e689 --- /dev/null +++ b/crates/qbz-slint/ui/assets/icons/qobuz-logo.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/crates/qbz-slint/ui/assets/icons/spotify-logo.svg b/crates/qbz-slint/ui/assets/icons/spotify-logo.svg new file mode 100644 index 000000000..cf4b003f0 --- /dev/null +++ b/crates/qbz-slint/ui/assets/icons/spotify-logo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-slint/ui/assets/icons/tidal-tidal.svg b/crates/qbz-slint/ui/assets/icons/tidal-tidal.svg new file mode 100644 index 000000000..7487d81a3 --- /dev/null +++ b/crates/qbz-slint/ui/assets/icons/tidal-tidal.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/crates/qbz-slint/ui/primitives/PlaylistDuplicateConfirmModal.slint b/crates/qbz-slint/ui/primitives/PlaylistDuplicateConfirmModal.slint new file mode 100644 index 000000000..0e820dcc2 --- /dev/null +++ b/crates/qbz-slint/ui/primitives/PlaylistDuplicateConfirmModal.slint @@ -0,0 +1,129 @@ +// Duplicate-tracks confirm sub-modal — 1:1 with Tauri's +// PlaylistDuplicateConfirmModal.svelte. Shown when a Qobuz-tracks → +// Qobuz-playlist add finds tracks already in the target playlist. Offers +// "Add all tracks" (re-add the duplicates too) or "Add only new tracks" +// (skip duplicates); dismissal is the X / scrim / Escape (Tauri has no +// footer Cancel). The track-id Vec + duplicate id set live Rust-side +// in a stash cell; this modal only renders the counts and routes the choice. +// +// Mounted in AppShell AFTER PlaylistPickerModal so it stacks above it +// (Slint stacking = declaration order; ADR-009 >= 3000 satisfied by being +// declared last). Conditional mount via `if DuplicateConfirmState.open` +// (ADR-010). Matches the Rectangle-scrim + centered-card pattern of +// PlaylistPickerModal (no PopupWindow, no shared Modal wrapper). + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Typography } from "../foundation/typography.slint"; +import { Radius } from "../foundation/radius.slint"; +import { DuplicateConfirmState, DuplicateConfirmActions } from "../state.slint"; +import { QbzIcon } from "QbzIcon.slint"; +import { QbzPrimaryButton } from "QbzPrimaryButton.slint"; +import { SecondaryButton } from "SecondaryButton.slint"; + +export component PlaylistDuplicateConfirmModal inherits Rectangle { + visible: DuplicateConfirmState.open; + + if DuplicateConfirmState.open: Rectangle { + background: #000000bf; + // Scrim — click outside to dismiss (gated by busy so a click can't + // cancel mid-add). + TouchArea { + mouse-cursor: default; + clicked => { + if (!DuplicateConfirmState.busy) { + DuplicateConfirmActions.cancel(); + } + } + } + Rectangle { + width: Math.min(root.width - 80px, 480px); + height: panel.preferred-height; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + border-radius: Radius.md; + // Tauri's ModalLite card is --bg-primary (#0f0f0f), darker + // than the picker card. + background: Theme.surface-main; + border-width: 1px; + border-color: Theme.border-subtle; + drop-shadow-blur: 32px; + drop-shadow-color: #00000080; + // Swallow clicks so they don't reach the scrim. + TouchArea { } + + panel := VerticalLayout { + padding: 20px; + spacing: 16px; + + // Title + close. + HorizontalLayout { + Text { + text: @tr("Some of these tracks were already added"); + color: Theme.text-primary; + font-size: Typography.heading; + font-weight: Typography.semibold; + horizontal-stretch: 1; + vertical-alignment: center; + } + close-x := TouchArea { + width: 28px; + height: 28px; + mouse-cursor: DuplicateConfirmState.busy ? default : pointer; + enabled: !DuplicateConfirmState.busy; + clicked => { + DuplicateConfirmActions.cancel(); + } + QbzIcon { + source: @image-url("../assets/icons/x.svg"); + width: 17px; + height: 17px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted; + } + } + } + + // Body: how many duplicates, and what each choice does. + Text { + text: @tr("This playlist already contains {} of the {} tracks you're adding. Add only the new ones, or add everything including duplicates.", + DuplicateConfirmState.duplicate-count, + DuplicateConfirmState.total-count); + color: Theme.text-secondary; + font-size: Typography.body; + wrap: word-wrap; + } + + // Footer: Add all tracks / Add only new tracks. No Cancel — + // dismissal is the header X / scrim / Escape (Tauri parity). + HorizontalLayout { + alignment: end; + spacing: 10px; + + // Add all tracks — re-adds the duplicates too + // (executeAddTracks(false)). + SecondaryButton { + label: DuplicateConfirmState.busy ? @tr("Adding...") : @tr("Add all tracks"); + enabled: !DuplicateConfirmState.busy; + clicked => { + DuplicateConfirmActions.add-all(); + } + } + // Add only new tracks — skips the duplicates + // (executeAddTracks(true)). Disabled when there is + // nothing left to add (all dupes). + QbzPrimaryButton { + label: DuplicateConfirmState.busy ? @tr("Adding...") : @tr("Add only new tracks"); + height: 34px; + enabled: !DuplicateConfirmState.busy + && DuplicateConfirmState.duplicate-count + < DuplicateConfirmState.total-count; + clicked => { + DuplicateConfirmActions.add-new-only(); + } + } + } + } + } + } +} diff --git a/crates/qbz-slint/ui/primitives/PlaylistImportModal.slint b/crates/qbz-slint/ui/primitives/PlaylistImportModal.slint new file mode 100644 index 000000000..fd4fd71fb --- /dev/null +++ b/crates/qbz-slint/ui/primitives/PlaylistImportModal.slint @@ -0,0 +1,560 @@ +// "Import Playlist" modal — 1:1 port of Tauri's PlaylistImportModal.svelte. +// Two-step flow: URL entry → provider auto-detect (Rust-side; Slint 1.16 +// has no string `.contains`) → fetch preview → rename + optional folder → +// import with a live progress bar, status line and append-only log, then +// an in-panel Summary block. All interpolated strings arrive pre-formatted +// from the Rust controller; the modal renders them verbatim. +// +// Documented visual deviation (port spec §1.5): Slint/femtovg has no +// grayscale/drop-shadow image filters, so the provider logos homologate to +// opacity 0.45 idle / 1.0 active (no glow/translate/scale), and Tidal's +// black SVG renders through QbzIcon tinted white so it reads on dark +// surfaces in both states. +// +// Close semantics (port spec §1.8): footer Close disables while loading +// (Tauri-verbatim); the header X and the scrim stay clickable mid-import — +// closing only hides the UI, the import task runs to completion. +// +// CJK/femtovg: playlist names, the current-track line and log lines can +// hold Korean/CJK, which femtovg measures at ~0 in both axes — every such +// Text gets BOTH width and height assigned from layout (stretch + fixed +// row heights, overflow: elide), never intrinsic sizing. + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Typography } from "../foundation/typography.slint"; +import { Radius } from "../foundation/radius.slint"; +import { PlaylistImportState, PlaylistImportActions, OfflineState } from "../state.slint"; +import { QbzIcon } from "QbzIcon.slint"; +import { QbzSelect } from "QbzSelect.slint"; +import { LoadingSpinner } from "LoadingSpinner.slint"; +import { QbzPrimaryButton } from "QbzPrimaryButton.slint"; +import { SecondaryButton } from "SecondaryButton.slint"; + +export component PlaylistImportModal inherits Rectangle { + visible: PlaylistImportState.open; + + if PlaylistImportState.open: Rectangle { + background: #000000bf; + // Scrim — click outside dismisses, even mid-import (§1.8: closing + // only hides the UI, the tokio task keeps running). + TouchArea { + mouse-cursor: default; + clicked => { + PlaylistImportActions.close(); + } + } + + card := Rectangle { + // Tauri: 560px max, min-height 420px, max-height 90vh. + width: Math.min(root.width - 80px, 560px); + // 140px = header 68 + 2 hairlines + footer 70 (16/34/20); the + // body scrolls inside a Flickable past the 90% clamp. + height: Math.min(Math.max(420px, body.preferred-height + 140px), root.height * 0.9); + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + border-radius: Radius.lg; + background: Theme.surface-card; + border-width: 1px; + border-color: Theme.border-subtle; + drop-shadow-blur: 32px; + drop-shadow-color: #00000080; + // Swallow clicks so they never reach the scrim. + TouchArea { } + + panel := VerticalLayout { + padding: 0px; + spacing: 0px; + + // Header: Qobuz logo + title + close-x. + header := HorizontalLayout { + padding-left: 24px; + padding-right: 24px; + padding-top: 20px; + padding-bottom: 20px; + spacing: 10px; + VerticalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/qobuz-logo.svg"); + width: 26px; + height: 26px; + tint: #ffffff; + opacity: 0.9; + } + } + Text { + text: @tr("Import Playlist"); + color: Theme.text-primary; + font-size: Typography.section; + font-weight: Typography.semibold; + horizontal-stretch: 1; + vertical-alignment: center; + } + // X close — allowed mid-import (§1.8). + close-x := TouchArea { + width: 28px; + height: 28px; + mouse-cursor: pointer; + clicked => { + PlaylistImportActions.close(); + } + QbzIcon { + source: @image-url("../assets/icons/x.svg"); + width: 17px; + height: 17px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted; + } + } + } + Rectangle { + height: 1px; + background: Theme.border-subtle; + } + + // Body — scrolls when the progress panel outgrows the card. + body-flick := Flickable { + vertical-stretch: 1; + viewport-width: self.width; + viewport-height: body.preferred-height; + + body := VerticalLayout { + padding: 24px; + spacing: 16px; + alignment: start; + + // Offline banner — fetch is gated (can-fetch goes + // false), the URL input stays enabled. + if OfflineState.offline: Rectangle { + height: offline-row.preferred-height; + border-radius: 8px; + background: #eab3081a; + border-width: 1px; + border-color: #eab3084d; + offline-row := HorizontalLayout { + padding: 12px; + spacing: 10px; + VerticalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/cloud-off.svg"); + width: 16px; + height: 16px; + tint: #eab308; + } + } + Text { + text: @tr("This feature requires internet connection"); + color: #eab308; + font-size: Typography.legal; + vertical-alignment: center; + horizontal-stretch: 1; + } + } + } + + // Error banner (preview or execute failure). + if PlaylistImportState.error != "": Rectangle { + height: error-row.preferred-height; + border-radius: 8px; + background: #ef44441a; + border-width: 1px; + border-color: #ef44444d; + error-row := HorizontalLayout { + padding: 12px; + Text { + text: PlaylistImportState.error; + color: #ef4444; + font-size: Typography.legal; + wrap: word-wrap; + min-height: 20px; + horizontal-stretch: 1; + } + } + } + + // URL input — every keystroke routes the detection + // through Rust (url-edited); Enter fetches (step A). + VerticalLayout { + spacing: 8px; + Text { + text: @tr("Playlist URL"); + color: Theme.text-secondary; + font-size: Typography.legal; + font-weight: Typography.medium; + } + // The standard 43px input box (PlaylistPicker + // recipe). + url-box := Rectangle { + height: 43px; + border-radius: Radius.sm; + background: Theme.surface-card; + border-width: 1px; + border-color: url-input.has-focus ? Theme.accent : Theme.border-subtle; + opacity: PlaylistImportState.loading ? 0.5 : 1.0; + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + Rectangle { + horizontal-stretch: 1; + clip: true; + url-input := TextInput { + width: 100%; + height: 100%; + color: Theme.text-primary; + font-size: Typography.link; + vertical-alignment: center; + single-line: true; + enabled: !PlaylistImportState.loading; + text <=> PlaylistImportState.url; + edited => { + PlaylistImportActions.url-edited(self.text); + } + accepted => { + if (!PlaylistImportState.show-preview + && PlaylistImportState.can-fetch + && !PlaylistImportState.loading) { + PlaylistImportActions.fetch(); + } + } + } + Text { + // Placeholder — Slint TextInput has + // no placeholder property; a sibling + // Text gated on empty text. Plain + // string: not localized in Tauri + // either. + visible: url-input.text == ""; + width: 100%; + height: 100%; + text: "https://open.spotify.com/playlist/..."; + color: Theme.text-muted; + font-size: Typography.link; + vertical-alignment: center; + overflow: elide; + } + } + } + } + } + + // Allowed sources — the detected provider's logo at + // full opacity, the rest dimmed (§1.5 homologation). + VerticalLayout { + spacing: 8px; + Text { + text: @tr("ALLOWED SOURCES"); + color: Theme.text-muted; + font-size: Typography.legal; + font-weight: Typography.semibold; + letter-spacing: 0.5px; + } + HorizontalLayout { + spacing: 10px; + alignment: start; + Image { + source: @image-url("../assets/icons/spotify-logo.svg"); + width: 70px; + height: 24px; + image-fit: contain; + opacity: PlaylistImportState.active-provider == "spotify" ? 1.0 : 0.45; + } + Image { + source: @image-url("../assets/icons/apple-music-logo.svg"); + width: 70px; + height: 24px; + image-fit: contain; + opacity: PlaylistImportState.active-provider == "apple" ? 1.0 : 0.45; + } + // Tidal's SVG is black — tint white so it + // reads on dark surfaces in both states. + QbzIcon { + source: @image-url("../assets/icons/tidal-tidal.svg"); + width: 70px; + height: 24px; + tint: #ffffff; + opacity: PlaylistImportState.active-provider == "tidal" ? 1.0 : 0.45; + } + Image { + source: @image-url("../assets/icons/deezer-logo.svg"); + width: 70px; + height: 24px; + image-fit: contain; + opacity: PlaylistImportState.active-provider == "deezer" ? 1.0 : 0.45; + } + } + } + + // Customization panel (step B): rename + optional + // folder. Editing the URL reverts to step A + // (show-preview recomputes Rust-side). + if PlaylistImportState.show-preview: Rectangle { + height: custom-col.preferred-height; + border-radius: Radius.md; + background: Theme.surface-elevated; + border-width: 1px; + border-color: Theme.border-subtle; + custom-col := VerticalLayout { + padding: 16px; + spacing: 12px; + VerticalLayout { + spacing: 8px; + Text { + text: @tr("Playlist name"); + color: Theme.text-secondary; + font-size: Typography.legal; + font-weight: Typography.medium; + } + name-box := Rectangle { + height: 43px; + border-radius: Radius.sm; + background: Theme.surface-card; + border-width: 1px; + border-color: name-input.has-focus ? Theme.accent : Theme.border-subtle; + opacity: name-input.enabled ? 1.0 : 0.5; + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + Rectangle { + horizontal-stretch: 1; + clip: true; + name-input := TextInput { + width: 100%; + height: 100%; + color: Theme.text-primary; + font-size: Typography.link; + vertical-alignment: center; + single-line: true; + enabled: !PlaylistImportState.loading + && !PlaylistImportState.import-completed; + text <=> PlaylistImportState.custom-name; + edited => { + PlaylistImportActions.name-edited(self.text); + } + accepted => { + // Enter here executes (step B). + if (!PlaylistImportState.loading + && !PlaylistImportState.import-completed) { + PlaylistImportActions.execute(); + } + } + } + } + } + } + } + // Folder dropdown — only when the user has + // folders (mirrors Tauri; index 0 = "No + // folder"). + if PlaylistImportState.folder-options.length > 1: VerticalLayout { + spacing: 8px; + Text { + text: @tr("Folder"); + color: Theme.text-secondary; + font-size: Typography.legal; + font-weight: Typography.medium; + } + QbzSelect { + options: PlaylistImportState.folder-options; + current-index: PlaylistImportState.folder-index; + menu-width: 380px; + selected(i) => { + PlaylistImportState.folder-index = i; + } + } + } + } + } + + // Progress panel: spinner header, determinate bar, + // status + current-track lines, the log, and the + // Summary block on completion. + if PlaylistImportState.progress-visible: Rectangle { + height: progress-col.preferred-height; + border-radius: Radius.md; + background: Theme.surface-elevated; + border-width: 1px; + border-color: Theme.border-subtle; + progress-col := VerticalLayout { + padding: 16px; + spacing: 10px; + HorizontalLayout { + spacing: 8px; + Text { + text: @tr("Conversion progress"); + color: Theme.text-primary; + font-size: Typography.body; + font-weight: Typography.medium; + vertical-alignment: center; + horizontal-stretch: 1; + } + // Tauri: 14px accent ring while loading. + if PlaylistImportState.loading: VerticalLayout { + alignment: center; + LoadingSpinner { + size: 14px; + } + } + } + if PlaylistImportState.has-progress: VerticalLayout { + spacing: 6px; + // Determinate 6px bar (LocalLibrarySettings + // recipe); track in the 8%-alpha tone — the + // panel bg already uses surface-elevated. + Rectangle { + height: 6px; + border-radius: 3px; + background: Theme.border-subtle; + clip: true; + Rectangle { + x: 0; + width: parent.width * Math.min(1.0, PlaylistImportState.progress); + height: parent.height; + border-radius: 3px; + background: Theme.accent; + } + } + // Status line — 12px Tauri metric; fixed + // height (CJK floor) + elide. + Text { + text: PlaylistImportState.status-line; + color: Theme.text-secondary; + font-size: 12px; + height: 18px; + vertical-alignment: center; + overflow: elide; + } + // Current-track line — "Artist - Title" + // while matching, "Part i/n" while adding. + if PlaylistImportState.current-track != "": Text { + text: PlaylistImportState.current-track; + color: Theme.text-muted; + font-size: 11px; + height: 16px; + vertical-alignment: center; + overflow: elide; + } + } + // Append-only log. Height capped via static + // arithmetic over the MODEL LENGTH — never row + // geometry (Recursion-detected footgun). No + // autoscroll (Tauri parity). Render-only rows: + // no TouchAreas here. + if PlaylistImportState.log.length > 0: Flickable { + height: Math.min(PlaylistImportState.log.length * 20px, 160px); + viewport-width: self.width; + viewport-height: PlaylistImportState.log.length * 20px; + VerticalLayout { + spacing: 0px; + for entry in PlaylistImportState.log: Rectangle { + height: 20px; + Text { + width: 100%; + height: 100%; + text: entry.message; + // Hex literals kept — Tauri + // hardcodes them. + color: entry.status == "success" ? #34d399 + : (entry.status == "error" ? #f87171 : Theme.text-muted); + font-size: Typography.legal; + vertical-alignment: center; + overflow: elide; + } + } + } + } + // Summary block — pre-formatted lines, hairline + // separated, "" = hidden. + if PlaylistImportState.summary-playlist != "": VerticalLayout { + spacing: 6px; + Rectangle { + height: 1px; + background: Theme.border-subtle; + } + Text { + text: @tr("Summary"); + color: Theme.text-primary; + font-size: Typography.body; + font-weight: Typography.semibold; + } + Text { + text: PlaylistImportState.summary-playlist; + color: Theme.text-secondary; + font-size: Typography.legal; + height: 18px; + vertical-alignment: center; + overflow: elide; + } + Text { + text: PlaylistImportState.summary-matched; + color: Theme.text-secondary; + font-size: Typography.legal; + height: 18px; + vertical-alignment: center; + overflow: elide; + } + Text { + text: PlaylistImportState.summary-skipped; + color: Theme.text-secondary; + font-size: Typography.legal; + height: 18px; + vertical-alignment: center; + overflow: elide; + } + if PlaylistImportState.summary-parts != "": Text { + text: PlaylistImportState.summary-parts; + color: Theme.text-secondary; + font-size: Typography.legal; + height: 18px; + vertical-alignment: center; + overflow: elide; + } + } + } + } + } + } + + Rectangle { + height: 1px; + background: Theme.border-subtle; + } + footer := HorizontalLayout { + padding-left: 24px; + padding-right: 24px; + padding-top: 16px; + padding-bottom: 20px; + alignment: end; + spacing: 12px; + SecondaryButton { + label: @tr("Close"); + enabled: !PlaylistImportState.loading; + clicked => { + PlaylistImportActions.close(); + } + } + // The single accent confirm (ADR-008). Step A fetches, + // step B imports; stays visible-but-disabled after + // completion (Tauri parity). + QbzPrimaryButton { + height: 34px; + label: PlaylistImportState.show-preview + ? (PlaylistImportState.loading ? @tr("Importing...") : @tr("Import")) + : (PlaylistImportState.loading ? @tr("Fetching...") : @tr("Fetch playlist")); + enabled: PlaylistImportState.show-preview + ? (!PlaylistImportState.loading && !PlaylistImportState.import-completed) + : (PlaylistImportState.can-fetch && !PlaylistImportState.loading); + clicked => { + if (PlaylistImportState.show-preview) { + PlaylistImportActions.execute(); + } else { + PlaylistImportActions.fetch(); + } + } + } + } + } + } + } +} diff --git a/crates/qbz-slint/ui/primitives/PlaylistPickerModal.slint b/crates/qbz-slint/ui/primitives/PlaylistPickerModal.slint index 408a0a30f..19a6c9255 100644 --- a/crates/qbz-slint/ui/primitives/PlaylistPickerModal.slint +++ b/crates/qbz-slint/ui/primitives/PlaylistPickerModal.slint @@ -1,6 +1,15 @@ -// "Add to playlist" picker — a global modal overlay. Lists the user's -// playlists; clicking one adds the pending track to it. Opened from -// the track context menu (PlaylistPickerState.open). +// "Add to Playlist" picker — a global modal overlay, 1:1 with Tauri's +// PlaylistModal.svelte (addTrack mode). A focus-opened dropdown lists +// the user's playlists; clicking a row only SELECTS it (the selection +// becomes the input's placeholder); the footer Add button fires the +// actual pick. "+ Create new playlist" (first dropdown row) reveals an +// inline name field confirmed by the same footer button ("Create & Add"). +// Opened from the track context menu (PlaylistPickerState.open). +// +// Filter matching runs Rust-side (Slint 1.16 has no string `.contains`): +// every keystroke recomputes each row's `filter-rank` (match ordinal, +// -1 = filtered out) plus `filter-matches`; ranks >= 8 hide behind the +// "{} more playlist(s)..." overflow row. import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; @@ -8,62 +17,71 @@ import { Radius } from "../foundation/radius.slint"; import { PlaylistPickerState, PlaylistPickerActions, PlaylistPickItem } from "../state.slint"; import { QbzIcon } from "QbzIcon.slint"; import { LoadingSpinner } from "LoadingSpinner.slint"; +import { QbzPrimaryButton } from "QbzPrimaryButton.slint"; +import { SecondaryButton } from "SecondaryButton.slint"; -component PickRow inherits Rectangle { +// One flat dropdown row (Tauri parity: single 41px line, name + count, +// hover fill only — no icon tile, no zebra). Rows filtered out +// (filter-rank == -1) or ranked past the 8-row window collapse to zero +// height; the list layout's spacing is 0 so nothing leaks. The height +// binding reads only model data — never layout-managed geometry. +component DropRow inherits Rectangle { in property item; - in property index; - callback clicked(string); - height: 52px; - border-radius: 8px; - background: ta.has-hover - ? #ffffff16 - : (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent); - HorizontalLayout { - padding-left: 12px; - padding-right: 12px; - spacing: 12px; - VerticalLayout { - alignment: center; - Rectangle { - width: 34px; - height: 34px; - border-radius: 6px; - background: Theme.surface-elevated; - QbzIcon { - // LOCAL playlists (library.db) get the hard-drive glyph - // — adds write the local repo and work offline. - source: root.item.is-local - ? @image-url("../assets/icons/hard-drive.svg") - : @image-url("../assets/icons/list-music.svg"); - width: 18px; - height: 18px; - x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; - y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; - tint: Theme.text-muted; - } - } - } - VerticalLayout { - alignment: center; - horizontal-stretch: 1; - Text { - text: root.item.name; - color: Theme.text-primary; - font-size: Typography.body; - font-weight: Typography.medium; - overflow: elide; - } - if root.item.tracks-line != "": Text { - text: root.item.tracks-line; - color: Theme.text-muted; - font-size: Typography.legal; - } + // Keyboard highlight (arrow nav) — painted like hover. + in property highlighted; + // Enter-to-select dispatch: when `select-seq` bumps, the row whose + // filter-rank equals `select-rank` fires `select()`. + in property select-seq; + in property select-rank: -1; + callback select(); + + visible: root.item.filter-rank >= 0 && root.item.filter-rank < 8; + height: (root.item.filter-rank >= 0 && root.item.filter-rank < 8) ? 41px : 0px; + background: (ta.has-hover || root.highlighted) ? Theme.surface-hover : transparent; + + changed select-seq => { + if (root.item.filter-rank >= 0 && root.item.filter-rank == root.select-rank) { + root.select(); } } + + // Row-level TouchArea declared FIRST, cell content after. ta := TouchArea { mouse-cursor: pointer; clicked => { - root.clicked(root.item.id); + root.select(); + } + } + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + padding-top: 10px; + padding-bottom: 10px; + spacing: 8px; + // LOCAL playlists (library.db) keep a small hard-drive glyph — + // deliberate Slint extra; locals don't exist in Tauri's picker. + if root.item.is-local: VerticalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/hard-drive.svg"); + width: 14px; + height: 14px; + tint: Theme.text-muted; + } + } + Text { + text: root.item.name; + color: Theme.text-primary; + font-size: Typography.link; + vertical-alignment: center; + overflow: elide; + horizontal-stretch: 1; + } + Text { + text: root.item.tracks-line; + color: Theme.text-muted; + font-size: Typography.legal; + vertical-alignment: center; } } } @@ -80,73 +98,523 @@ export component PlaylistPickerModal inherits Rectangle { PlaylistPickerActions.close(); } } - Rectangle { - width: Math.min(root.width - 80px, 460px); - height: Math.min(root.height - 120px, 520px); + + // Card-sized FocusScope: keyboard nav + the picker's local state. + // The locals live on the `if open:` subtree so every conditional + // remount resets them — no state.slint props, no Rust resets. + keys := FocusScope { + // Dropdown visibility + the select-then-Add selection. + property dropdown-open: false; + property selected-id: ""; // "" = nothing selected + property selected-label: ""; // "{name} ({tracks-line})" placeholder + // Keyboard highlight over shown + 1 virtual rows; -1 = none, + // 0 = the create-new row, 1.. = visible playlist rows. + property highlight: -1; + // Bumped by Enter; the rank-matching DropRow selects itself. + property select-seq: 0; + property nav-count: Math.min(PlaylistPickerState.filter-matches, 8) + 1; + property adding-count: Math.max(1, PlaylistPickerState.track-ids.length); + + width: Math.min(root.width - 80px, 490px); + // Tauri: min-height 400px / max-height 90vh — the body + // stretches so the footer pins to the bottom and blank space + // sits under the picker group. + height: Math.min(Math.max(400px, panel.preferred-height), root.height - 80px); x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; - border-radius: Radius.md; - background: Theme.surface-card; - border-width: 1px; - border-color: Theme.border-subtle; - drop-shadow-blur: 32px; - drop-shadow-color: #00000080; - // Swallow clicks so they don't reach the scrim. - TouchArea { } + forward-focus: search-input; - VerticalLayout { - padding: 20px; - spacing: 14px; - - HorizontalLayout { - Text { - text: @tr("Add to playlist"); - color: Theme.text-primary; - font-size: Typography.heading; - font-weight: Typography.semibold; - horizontal-stretch: 1; - vertical-alignment: center; + // Enter semantics: dropdown open → select the highlighted row + // (select only, never submit); dropdown closed → act as the + // footer Add button. + function on-enter() { + if (self.dropdown-open) { + if (self.highlight >= 0) { + self.commit-highlight(); } - close-x := TouchArea { - width: 28px; - height: 28px; - mouse-cursor: pointer; - clicked => { - PlaylistPickerActions.close(); + } else if (!PlaylistPickerState.creating) { + if (PlaylistPickerState.creating-open) { + if (PlaylistPickerState.create-name != "") { + PlaylistPickerActions.create-and-add(); } - QbzIcon { - source: @image-url("../assets/icons/x.svg"); - width: 17px; - height: 17px; - x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; - y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; - tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted; + } else if (self.selected-id != "") { + PlaylistPickerActions.pick(self.selected-id); + } + } + } + function commit-highlight() { + if (self.highlight == 0) { + // Virtual row 0 = "+ Create new playlist". + self.selected-id = ""; + self.selected-label = ""; + PlaylistPickerState.creating-open = true; + PlaylistPickerState.filter = ""; + PlaylistPickerActions.filter-changed(""); + self.dropdown-open = false; + self.highlight = -1; + } else if (self.highlight > 0) { + self.select-seq += 1; + } + } + + key-pressed(event) => { + if (event.text == Key.DownArrow) { + if (!self.dropdown-open) { + self.dropdown-open = true; + self.highlight = 0; + } else { + self.highlight = Math.mod(self.highlight + 1, self.nav-count); + } + return accept; + } + if (event.text == Key.UpArrow) { + if (!self.dropdown-open) { + self.dropdown-open = true; + self.highlight = 0; + } else if (self.highlight <= 0) { + self.highlight = self.nav-count - 1; + } else { + self.highlight -= 1; + } + return accept; + } + if (event.text == Key.Escape) { + // First Escape closes the dropdown, second the modal. + if (self.dropdown-open) { + self.dropdown-open = false; + self.highlight = -1; + } else { + PlaylistPickerActions.close(); + } + return accept; + } + if (event.text == Key.Return) { + self.on-enter(); + return accept; + } + return reject; + } + + // Deferred mount focus — synchronous focus during the remount + // races layout (see ExpandableSearch); a one-shot timer grabs + // it a tick later. forward-focus routes it into the input. + focus-timer := Timer { + interval: 30ms; + running: true; + triggered => { + self.running = false; + keys.focus(); + } + } + + card := Rectangle { + border-radius: Radius.lg; + background: Theme.surface-card; + border-width: 1px; + border-color: Theme.border-subtle; + drop-shadow-blur: 32px; + drop-shadow-color: #00000080; + // NO clip — the dropdown may overflow the card bottom, + // like Tauri's overflow: visible. + + // Swallow clicks so they don't reach the scrim; doubles as + // "click inside the card closes the list" (Tauri parity). + TouchArea { + clicked => { + keys.dropdown-open = false; + keys.highlight = -1; + } + } + + panel := VerticalLayout { + padding: 0px; + spacing: 0px; + + // Header: title + close-x. + header := HorizontalLayout { + padding-left: 24px; + padding-right: 24px; + padding-top: 20px; + padding-bottom: 20px; + Text { + text: @tr("Add to Playlist"); + color: Theme.text-primary; + font-size: Typography.section; + font-weight: Typography.semibold; + horizontal-stretch: 1; + vertical-alignment: center; + } + close-x := TouchArea { + width: 28px; + height: 28px; + mouse-cursor: pointer; + clicked => { + PlaylistPickerActions.close(); + } + QbzIcon { + source: @image-url("../assets/icons/x.svg"); + width: 17px; + height: 17px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted; + } + } + } + Rectangle { + height: 1px; + background: Theme.border-subtle; + } + + body := VerticalLayout { + padding: 24px; + spacing: 16px; + vertical-stretch: 1; + alignment: start; + + if PlaylistPickerState.loading: HorizontalLayout { + alignment: center; + LoadingSpinner { size: 30px; } + } + + picker-group := VerticalLayout { + spacing: 8px; + label-row := HorizontalLayout { + height: 20px; + Text { + text: @tr("Choose playlist"); + color: Theme.text-secondary; + font-size: Typography.legal; + font-weight: Typography.medium; + horizontal-stretch: 1; + vertical-alignment: center; + } + counter := Text { + // max(1, …): single-track opens carry + // track-id with track-ids empty. + text: PlaylistPickerState.local-mode + ? @tr("Adding {} local track(s)", keys.adding-count) + : @tr("Adding {} track(s)", keys.adding-count); + color: Theme.text-muted; + font-size: Typography.link; + vertical-alignment: center; + } + } + // The search/selection input — explicit 43px + // (Tauri metric; also keeps the card layout + // from dumping surplus height into it). + input-box := Rectangle { + height: 43px; + border-radius: Radius.sm; + background: Theme.surface-card; + border-width: 1px; + border-color: search-input.has-focus ? Theme.accent : Theme.border-subtle; + HorizontalLayout { + padding-left: 12px; + padding-right: 36px; // room for the clear chip + Rectangle { + horizontal-stretch: 1; + clip: true; + search-input := TextInput { + width: 100%; + height: 100%; + color: Theme.text-primary; + font-size: Typography.link; + vertical-alignment: center; + single-line: true; + enabled: !PlaylistPickerState.loading; + text <=> PlaylistPickerState.filter; + edited => { + keys.dropdown-open = true; // typing re-opens + keys.highlight = -1; + PlaylistPickerActions.filter-changed(self.text); + } + accepted => { + keys.on-enter(); + } + changed has-focus => { + // Tauri: list opens on focus. + if (self.has-focus) { + keys.dropdown-open = true; + } + } + } + Text { + // Placeholder — Slint TextInput has no + // placeholder property; a sibling Text + // gated on empty text. Doubles as the + // selection label (the filter is cleared + // on select, so text is empty then). + visible: search-input.text == ""; + width: 100%; + height: 100%; + text: PlaylistPickerState.creating-open + ? @tr("+ Create new playlist") + : (keys.selected-label != "" + ? keys.selected-label + : @tr("Search playlists...")); + color: Theme.text-muted; + font-size: Typography.link; + vertical-alignment: center; + overflow: elide; + } + } + } + // Clear-selection chip — only when a selection + // (or the inline-create state) exists and the + // list is closed. + if (keys.selected-id != "" || PlaylistPickerState.creating-open) + && !keys.dropdown-open: clear-chip := Rectangle { + width: 20px; + height: 20px; + x: parent.width - self.width - 10px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + border-radius: Radius.pill; + background: chip-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; + QbzIcon { + source: @image-url("../assets/icons/x.svg"); + width: 12px; + height: 12px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: chip-ta.has-hover ? Theme.text-primary : Theme.text-muted; + } + chip-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + keys.selected-id = ""; + keys.selected-label = ""; + PlaylistPickerState.creating-open = false; + PlaylistPickerState.create-name = ""; + } + } + } + } + } + + // Inline create — a second form group ADDED below the + // picker (Tauri §1.4.6); the footer button confirms. + if PlaylistPickerState.creating-open: create-group := VerticalLayout { + spacing: 8px; + Text { + text: @tr("Playlist Name"); + color: Theme.text-secondary; + font-size: Typography.legal; + font-weight: Typography.medium; + } + name-box := Rectangle { + height: 43px; + border-radius: Radius.sm; + background: Theme.surface-card; + border-width: 1px; + border-color: name-input.has-focus ? Theme.accent : Theme.border-subtle; + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + Rectangle { + horizontal-stretch: 1; + clip: true; + name-input := TextInput { + width: 100%; + height: 100%; + color: Theme.text-primary; + font-size: Typography.link; + vertical-alignment: center; + single-line: true; + enabled: !PlaylistPickerState.creating; + text <=> PlaylistPickerState.create-name; + accepted => { + if (PlaylistPickerState.create-name != "" + && !PlaylistPickerState.creating) { + PlaylistPickerActions.create-and-add(); + } + } + } + Text { + visible: name-input.text == ""; + width: 100%; + height: 100%; + text: @tr("My Playlist"); + color: Theme.text-muted; + font-size: Typography.link; + vertical-alignment: center; + overflow: elide; + } + } + } + } + } + } + + Rectangle { + height: 1px; + background: Theme.border-subtle; + } + footer := HorizontalLayout { + padding-left: 24px; + padding-right: 24px; + padding-top: 16px; + padding-bottom: 16px; + alignment: end; + spacing: 12px; + SecondaryButton { + label: @tr("Cancel"); + clicked => { + PlaylistPickerActions.close(); + } + } + // The single accent confirm (ADR-008). Disabled until + // valid — replaces Tauri's in-body error box. + QbzPrimaryButton { + height: 34px; + label: PlaylistPickerState.creating + ? @tr("Saving...") + : (PlaylistPickerState.creating-open ? @tr("Create & Add") : @tr("Add")); + enabled: !PlaylistPickerState.creating + && (PlaylistPickerState.creating-open + ? PlaylistPickerState.create-name != "" + : keys.selected-id != ""); + clicked => { + if (PlaylistPickerState.creating-open) { + PlaylistPickerActions.create-and-add(); + } else { + PlaylistPickerActions.pick(keys.selected-id); + } + } } } } - if PlaylistPickerState.loading: HorizontalLayout { - alignment: center; - LoadingSpinner { size: 30px; } - } - if !PlaylistPickerState.loading - && PlaylistPickerState.playlists.length == 0: Text { - text: @tr("No playlists yet."); - color: Theme.text-muted; - font-size: Typography.body; - } + // Dropdown overlay — declared LAST inside the card so it + // paints above the body/footer (ADR-009 declaration-order z) + // and legitimately overflows the unclipped card bottom. + if keys.dropdown-open: dropdown := Rectangle { + property shown: Math.min(PlaylistPickerState.filter-matches, 8); + // create-new row 41 + 1 divider, 41px per shown row, + // 41px no-results row, 1 + 34 overflow row. + property drop-content: 42px + + self.shown * 41px + + (PlaylistPickerState.filter-matches == 0 + && PlaylistPickerState.filter != "" ? 41px : 0px) + + (PlaylistPickerState.filter-matches > 8 ? 35px : 0px); - Flickable { - vertical-stretch: 1; - viewport-width: self.width; - viewport-height: list.preferred-height; - list := VerticalLayout { - spacing: 2px; - for pl[i] in PlaylistPickerState.playlists: PickRow { - item: pl; - index: i; - clicked(id) => { - PlaylistPickerActions.pick(id); + x: 24px; + // Anchor below the input — static arithmetic over fixed + // metrics (never an absolute-position binding): header 68 + // (20 pad + 28 close-x + 20 pad) + 1 divider + 24 body pad + // + 20 label row + 8 spacing + 43 input + 4 gap. + y: 68px + 1px + 24px + 20px + 8px + 43px + 4px; + width: parent.width - 48px; + height: Math.min(self.drop-content + 2px, 322px); // Tauri max-height 320 + border + background: Theme.surface-card; + border-width: 1px; + border-color: Theme.border-subtle; + border-radius: Radius.sm; + drop-shadow-blur: 24px; + drop-shadow-offset-y: 8px; + drop-shadow-color: Theme.card-shadow; + clip: true; + + // Keep clicks on the list chrome from reaching the card + // swallower (which would close the list). + TouchArea { } + + Flickable { + viewport-width: self.width; + viewport-height: inner.preferred-height; + inner := VerticalLayout { + spacing: 0px; + + // "+ Create new playlist" — ALWAYS first, never + // filtered out (Tauri parity). + create-row := Rectangle { + height: 41px; + background: (create-ta.has-hover || keys.highlight == 0) + ? Theme.surface-hover : transparent; + create-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + keys.selected-id = ""; + keys.selected-label = ""; + PlaylistPickerState.creating-open = true; + PlaylistPickerState.filter = ""; + PlaylistPickerActions.filter-changed(""); + keys.dropdown-open = false; + keys.highlight = -1; + } + } + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + padding-top: 10px; + padding-bottom: 10px; + Text { + text: @tr("+ Create new playlist"); + color: Theme.accent; + font-size: Typography.link; + font-weight: Typography.medium; + vertical-alignment: center; + overflow: elide; + } + } + } + Rectangle { + height: 1px; + background: Theme.border-subtle; + } + + for pl[i] in PlaylistPickerState.playlists: DropRow { + item: pl; + highlighted: pl.filter-rank >= 0 + && pl.filter-rank + 1 == keys.highlight; + select-seq: keys.select-seq; + select-rank: keys.highlight - 1; + select => { + keys.selected-id = self.item.id; + keys.selected-label = self.item.tracks-line == "" + ? self.item.name + : self.item.name + " (" + self.item.tracks-line + ")"; + PlaylistPickerState.creating-open = false; + // Tauri clears the query on selection (§2.4). + PlaylistPickerState.filter = ""; + PlaylistPickerActions.filter-changed(""); + keys.dropdown-open = false; + keys.highlight = -1; + } + } + + if PlaylistPickerState.filter != "" + && PlaylistPickerState.filter-matches == 0: Rectangle { + height: 41px; + Text { + width: 100%; + height: 100%; + text: @tr("No playlists found"); + color: Theme.text-muted; + font-size: Typography.legal; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + // Overflow row — ranks past the 8-row window. + if PlaylistPickerState.filter-matches > 8: Rectangle { + height: 1px; + background: Theme.border-subtle; + } + if PlaylistPickerState.filter-matches > 8: Rectangle { + height: 34px; + Text { + width: 100%; + height: 100%; + text: @tr("{} more playlist(s)...", + PlaylistPickerState.filter-matches - 8); + color: Theme.text-muted; + font-size: Typography.legal; + font-italic: true; + horizontal-alignment: center; + vertical-alignment: center; + } } } } diff --git a/crates/qbz-slint/ui/shell/AppShell.slint b/crates/qbz-slint/ui/shell/AppShell.slint index 2fa1a6d72..0b00ee11c 100644 --- a/crates/qbz-slint/ui/shell/AppShell.slint +++ b/crates/qbz-slint/ui/shell/AppShell.slint @@ -17,6 +17,7 @@ import { SidebarFolderPopup } from "SidebarFolderPopup.slint"; import { SidebarTooltip } from "SidebarTooltip.slint"; import { SidebarPlaylistsPopup } from "SidebarPlaylistsPopup.slint"; import { PlaylistPickerModal } from "../primitives/PlaylistPickerModal.slint"; +import { PlaylistDuplicateConfirmModal } from "../primitives/PlaylistDuplicateConfirmModal.slint"; import { CreatePlaylistModal } from "../primitives/CreatePlaylistModal.slint"; import { EditPlaylistModal } from "../primitives/EditPlaylistModal.slint"; import { CreateFolderModal } from "../primitives/CreateFolderModal.slint"; @@ -28,6 +29,7 @@ import { EphemeralPlayChoiceModal } from "../primitives/EphemeralPlayChoiceModal import { FolderEditModal } from "../primitives/FolderEditModal.slint"; import { LibFolderEditModal } from "../settings/LibFolderEditModal.slint"; import { TagEditorModal } from "../primitives/TagEditorModal.slint"; +import { PlaylistImportModal } from "../primitives/PlaylistImportModal.slint"; import { QconnectDevModal } from "QconnectDevModal.slint"; import { DragGhost } from "../primitives/DragGhost.slint"; import { Toast } from "../primitives/Toast.slint"; @@ -369,7 +371,11 @@ export component AppShell inherits Rectangle { // When both are open each section vertical-stretches to half; when // only one is open it fills. Rectangle { - width: (ShellState.queue-open || ShellState.lyrics-open) ? 280px : 0px; + // 300px so the lines/queue gain +20px of CONTENT width + // (padding/margins unchanged inside the panels). Both share + // the column (stacked mode), so both widen together — keeping + // Queue and Lyrics aligned when stacked. + width: (ShellState.queue-open || ShellState.lyrics-open) ? 300px : 0px; clip: true; animate width { duration: 160ms; easing: ease-in-out; } background: Theme.surface-card; @@ -428,6 +434,10 @@ export component AppShell inherits Rectangle { // context menu. PlaylistPickerModal { } + // "Duplicate tracks" confirm sub-modal — mounted AFTER the picker so it + // stacks above it (Slint stacking = declaration order; ADR-009/010). + PlaylistDuplicateConfirmModal { } + // Global "Create playlist" modal, opened from the sidebar "+". CreatePlaylistModal { } @@ -462,6 +472,12 @@ export component AppShell inherits Rectangle { // Global "Tag editor" modal (local album metadata). TagEditorModal { } + // Global "Import Playlist" modal (public-playlist importer). Mounted + // LAST among the feature modals so it stacks above them (ADR-009 + // declaration-order z; ADR-010 conditional mount via the global's + // `open`), keeping QconnectDevModal topmost. + PlaylistImportModal { } + // Qobuz Connect DEV diagnostics modal (opened from the connect flyout). QconnectDevModal { width: 100%; diff --git a/crates/qbz-slint/ui/shell/Sidebar.slint b/crates/qbz-slint/ui/shell/Sidebar.slint index 18064c64b..19f4049fb 100644 --- a/crates/qbz-slint/ui/shell/Sidebar.slint +++ b/crates/qbz-slint/ui/shell/Sidebar.slint @@ -25,10 +25,14 @@ component MenuItem inherits Rectangle { in property selected: false; in property icon; in property has-icon: false; + // When false the row is muted and ignores clicks (Import while + // offline). Same pattern as ContextMenuItem's enabled. + in property enabled: true; callback activated(); height: 30px; border-radius: 5px; - background: mi-ta.has-hover ? Theme.surface-hover : transparent; + background: (mi-ta.has-hover && root.enabled) ? Theme.surface-hover : transparent; + opacity: root.enabled ? 1.0 : 0.4; HorizontalLayout { padding-left: 8px; padding-right: 8px; @@ -63,9 +67,11 @@ component MenuItem inherits Rectangle { } } mi-ta := TouchArea { - mouse-cursor: pointer; + mouse-cursor: root.enabled ? pointer : default; clicked => { - root.activated(); + if (root.enabled) { + root.activated(); + } } } } @@ -849,6 +855,16 @@ export component Sidebar inherits Rectangle { icon: @image-url("../assets/icons/library-big.svg"); activated => { SidebarActions.manage-playlists(); } } + // Import playlist — open the importer modal. Dimmed + // + inert while offline; the modal's own offline + // banner is the second layer, exactly like Tauri. + MenuItem { + label: @tr("Import"); + has-icon: true; + icon: @image-url("../assets/icons/import.svg"); + enabled: !OfflineState.offline; + activated => { SidebarActions.import-playlist(); } + } // Refresh — re-fetch the playlist list. MenuItem { label: @tr("Refresh"); diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index bb3085aed..129fa14f2 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -40,7 +40,8 @@ export struct AlphaJump { // One button in the shared multi-select bulk bar (MultiSelectBar). export struct BulkAction { id: string, // emitted verbatim via MultiSelectBar.action(id) - label: string, // already-translated button label + label: string, // already-translated text — now the hover tooltip + icon: image, // the button glyph (the bar is icon-only) danger: bool, // red-bordered destructive button (e.g. Remove) needs-selection: bool, // dimmed + disabled while nothing is selected } @@ -288,6 +289,11 @@ export global AlbumState { in property artwork-url; in property has-custom-cover: false; in property info-line; + // Meta-line split around the label so the header can render the label + // as a clickable link: meta-pre (year) + [label] + meta-post + // (genre • N tracks • duration). info-line stays the fallback. + in property meta-pre; + in property meta-post; in property quality-tier; // "24-bit / 96 kHz" — quality-badge detail line. in property quality-detail; @@ -295,6 +301,10 @@ export global AlbumState { in property description; // Truncated description for the header; full text lives in the modal. in property description-short; + // Half-length truncation used when the content area is space-constrained + // (ShellState.content-constrained) so the header stays compact and the + // tracks keep priority. Falls back to the full short text when there's room. + in property description-shorter; in property artwork; // Artwork-derived tint for the header gradient. in property header-color: #181820; @@ -302,6 +312,25 @@ export global AlbumState { // Label id for the sidebar label card → label page navigation ("" = none). in property label-id; in property <[string]> awards: []; + // True when the album ships a downloadable booklet/liner-notes PDF + // (Qobuz goodies). Gates the booklet button in the header — not every + // album has one. + in property has-booklet: false; + // True when EVERY track of the album is cached offline (cache-status 3). + // Swaps the header menu's "Make available offline" for "Refresh offline + // copy" (Tauri's isAlbumFullyDownloaded). Kept live as downloads land. + in property album-fully-cached: false; + // Whether the album is in the user's favorites (library). Drives the + // header heart (filled accent when on). Seeded from fav_cache on open, + // flipped optimistically on toggle. + in property is-favorite: false; + // True while a favorite toggle is in flight (spinner on the heart). + in property favorite-loading: false; + // Multi-select (track selection) mode for the track list. in-out: the + // toolbar toggle flips it from the UI; Rust resets it on album change. + in-out property multi-select: false; + // Number of selected rows (drives the bulk bar count + select-all label). + in property selected-count: 0; in property <[TrackItem]> tracks: []; in property loading: false; // True when this is a Local Library album (local files / offline copies). @@ -316,6 +345,12 @@ export global AlbumState { export global AlbumActions { // Filter the visible track list by a query (client-side). callback search(string); + // Toggle multi-select (track selection) mode on the album track list. + callback toggle-multi-select(); + // Bulk-bar action over the selected rows (id = "select-all" | "play-next" + // | "queue" | "add-to-playlist" | "add-to-favorites" | "make-offline" | + // "clear"). + callback bulk-action(string); } // Track drag-and-drop onto sidebar playlists (a star QBZ feature). @@ -1128,6 +1163,8 @@ export global SidebarActions { callback refresh-playlists(); // Open the full Playlist Manager view. callback manage-playlists(); + // Open the playlist importer modal (sidebar "..." menu). + callback import-playlist(); } // "Create playlist" modal state. Mirrors Tauri's PlaylistModal (create @@ -2413,6 +2450,11 @@ export global ArtistState { // Artwork-derived tint for the header gradient. in property header-color: #181820; in property <[TrackItem]> top-tracks: []; + // Popular Tracks multi-select (track selection) mode + selected count — + // mirrors AlbumState. in-out: the section toggle flips it from the UI; + // Rust resets it on artist change. + in-out property top-tracks-multi-select: false; + in property top-tracks-selected-count: 0; // Releases grouped into titled sections (Albums, EPs & Singles, ...). in property <[DiscoverSection]> release-sections: []; // Labels collected from the artist's own album releases (deduped by @@ -2449,6 +2491,15 @@ export global ArtistActions { // a query string (client-side substring match, mirrors the album // track search in AlbumActions). callback search(string); + // Toggle Popular Tracks multi-select mode. + callback toggle-top-tracks-select(); + // Bulk-bar action over the selected Popular Tracks (id = "select-all" | + // "play-next" | "queue" | "add-to-playlist" | "add-to-favorites" | + // "clear"). + callback top-tracks-bulk-action(string); + // Popular Tracks section "more" menu (id = "next-all" | "queue-all" | + // "shuffle-all" | "playlist-all"). + callback top-tracks-menu-action(string); } // Artist network sidebar — Origin / Labels / Similar / Relationships / @@ -2746,6 +2797,16 @@ export global ShellState { // where lyrics closed queue and vice-versa.) in-out property queue-open: false; in-out property lyrics-open: false; + // Live WINDOW width (set from the AppWindow root). Views read it to make + // content-area responsiveness decisions independent of their own + // (sidebar-shrunk) width — e.g. the album Label/Awards sidebar collapses + // to an icon rail at the now-playing-bar breakpoint (1366px) WHEN a right + // panel is open. Defaults to the preferred window width. + in-out property window-width: 1180px; + // True when the content area is space-constrained: window at/under the + // NPB responsive breakpoint AND a right panel (Queue/Lyrics) is open. + out property content-constrained: + root.window-width < 1366px && (root.queue-open || root.lyrics-open); // Section navigation placement: the sections (Discover/Library/Local // Library/My QBZ) now live in the sidebar and open right-flyout menus. in-out property nav-in-sidebar: true; @@ -3067,6 +3128,11 @@ export struct PlaylistPickItem { // LOCAL playlist (library.db entity, id "local:") — the row // shows a hard-drive glyph; adds write the local repo (works offline). is-local: bool, + // Client-side filter result — recomputed Rust-side on every filter + // keystroke (Slint 1.16 has no string `.contains`, so the substring + // match lives in Rust). Match ordinal among the filtered rows; + // -1 = filtered out. Rows ranked >= 8 hide behind the overflow row. + filter-rank: int, } // "Add to playlist" picker — a global overlay opened from the track @@ -3081,11 +3147,49 @@ export global PlaylistPickerState { // True when track-ids are LocalLibrary row ids (i64) -> route to // add_local_track_to_playlist instead of the Qobuz endpoint. in property local-mode: false; + // Client-side filter: case-insensitive substring over playlist names. + // Pure frontend, no backend call. The list filters live as the user types. + in-out property filter: ""; + // Total rows matching the current filter — drives the dropdown's + // no-results/overflow rows and its explicit height. + in property filter-matches: 0; + // Inline "Create new playlist" affordance (mirrors Tauri's + // CREATE_NEW_PLAYLIST inline name field). When `creating-open` is true the + // name input is revealed; `create-name` holds the typed name; `creating` + // gates the confirm while the create-and-add runs. + in-out property creating-open: false; + in-out property create-name: ""; + in-out property creating: false; } export global PlaylistPickerActions { callback pick(string /* playlist id */); callback close(); + // Inline create-and-add: creates a new playlist (Qobuz online / local + // offline per D8) and adds the carried tracks to it. + callback create-and-add(); + // Client-side filter changed — Rust recomputes each row's filter-rank + // plus filter-matches (case-insensitive substring) since Slint 1.16 + // lacks string `.contains`. + callback filter-changed(string); +} + +// "Duplicate tracks" confirm sub-modal — shown when a Qobuz-tracks → Qobuz- +// playlist add finds tracks already in the target. The track-id Vec and +// the duplicate id set live Rust-side in a stash cell (Slint never holds +// them); this global only carries the counts + busy flag for rendering. +export global DuplicateConfirmState { + in-out property open: false; + in property duplicate-count: 0; + in property total-count: 0; + in property busy: false; + in property playlist-name; +} + +export global DuplicateConfirmActions { + callback add-all(); + callback add-new-only(); + callback cancel(); } // One artist tile in the Favorites > Artists grid. @@ -3354,3 +3458,71 @@ export global OfflineFavoritesActions { // queue path; the cached copies play through the offline tier). callback play(string); } + +// ================= Playlist Importer (public playlists) ================== +// "Import Playlist" modal state — 1:1 port of Tauri's +// PlaylistImportModal.svelte. All interpolated strings (log lines, status +// line, summary block) arrive pre-formatted from the Rust controller +// (crate::playlist_import); the UI renders them verbatim. Provider +// detection lives Rust-side too (Slint 1.16 has no string `.contains`) — +// every URL keystroke round-trips through `url-edited`. + +// One append-only line in the conversion log. +export struct ImportLogEntry { + // Final display string (pre-formatted Rust-side). + message: string, + // "info" | "success" | "error" — drives the row color. + status: string, +} + +export global PlaylistImportState { + in-out property open: false; + in-out property url; + // Rename field — prefilled with the fetched playlist name. + in-out property custom-name; + // True during preview fetch AND import execute. + in property loading: false; + // "" = none; shown as the red banner at the top of the body. + in property error: ""; + // "" | "spotify" | "apple" | "tidal" | "deezer" — the locked-or- + // detected provider; its source logo renders full-opacity. + in property active-provider: ""; + // Rust-computed: provider detected && !offline. + in property can-fetch: false; + // Rust-computed: a preview exists AND the URL hasn't been edited + // since the fetch — flips the modal into step B (rename + Import). + in property show-preview: false; + in property import-completed: false; + // The "Conversion progress" panel (any log lines, or loading). + in property progress-visible: false; + // Bar + status/current-track lines visible. + in property has-progress: false; + in property progress: 0.0; // 0..1 + in property status-line: ""; + in property current-track: ""; + in property <[ImportLogEntry]> log: []; + // Folder dropdown options — exact CreatePlaylistState pattern: index + // 0 is always "No folder" (id ""), the rest are the user's folder + // names parallel to `folder-ids`. + in property <[string]> folder-options: ["No folder"]; + in property <[string]> folder-ids: [""]; + in-out property folder-index: 0; + // Summary block, pre-formatted lines ("" = hidden). + in property summary-playlist: ""; + in property summary-matched: ""; + in property summary-skipped: ""; + in property summary-parts: ""; +} + +export global PlaylistImportActions { + callback close(); + // Fired on every URL keystroke — Rust recomputes active-provider / + // can-fetch / show-preview (and the post-completion reset path). + callback url-edited(string); + // Keeps Rust's custom_name mirror fresh. + callback name-edited(string); + // Step A: preview_public_playlist(url). + callback fetch(); + // Step B: import_public_playlist(...) with rename + folder choice. + callback execute(); +}