feat(slint): add-to-playlist duplicate modal + playlist importer port

Add-to-playlist (Slices 0-3):
- qbz-core: check_playlist_duplicates + pure compute_playlist_duplicates
  (reuses qbz_models::PlaylistDuplicateResult) + unit tests.
- PlaylistDuplicateConfirmModal (Add all / Add new only / Cancel); the
  duplicate check runs on the Qobuz->Qobuz picker branch only (local/Plex
  add directly, mirroring Tauri); the track-id Vec lives Rust-side in a
  stash, never in Slint.
- Inline create-and-add in the picker (online create_playlist, offline ->
  local playlist per D8) + client-side filter (Slint 1.16 has no string
  contains, routed through a Rust callback) + success toasts.

Playlist importer (1:1 Tauri port):
- New headless crate qbz-playlist-import (Spotify/Apple/Tidal/Deezer
  scrapers, Qobuz matcher, importer, progress sink, shared http client;
  copy-not-move from src-tauri, zero tauri deps; 40 unit tests green).
- PlaylistImportModal (two-step fetch-preview -> import with live progress
  bar / log / summary) + controller + sidebar PLAYLISTS menu entry point.

Reco logging staged behind TODO(reco-v1) markers (golden rule: playlist
recommendations ship in Slint v1 once the reco engine is extracted to a
shared crate).

cc
This commit is contained in:
vicrodh committed 2026-06-11 22:03:46 -06:00
1 parent 999ded11ff
commit 3689a5fda8
33 files changed
+5664 -129

No files matched your search

+16
View File
@@ -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",
+1
View File
@@ -25,6 +25,7 @@ members = [
"qbz-credentials",
"qbz-media-controls",
"qbz-mixtape",
"qbz-playlist-import",
"qbz-lyrics",
]
+74 -1
View File
@@ -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<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
.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<PlaylistDuplicateResult, CoreError> {
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<T>(items: &mut Vec<T>, 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<u64> = existing.iter().copied().collect();
let duplicate_track_ids: std::collections::HashSet<u64> = 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);
}
}
+28
View File
@@ -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 }
+20
View File
@@ -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),
}
+27
View File
@@ -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<reqwest::Client> = 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")
})
}
+136
View File
@@ -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<ImportPlaylist, PlaylistImportError> {
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<dyn ImportProgressSink>,
) -> Result<ImportSummary, PlaylistImportError> {
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,
})
}
+158
View File
@@ -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<ProviderKey> {
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<ProviderKey>)] = &[
// 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");
}
}
@@ -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<dyn ImportProgressSink>,
) -> Result<Vec<TrackMatch>, 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<tokio::sync::Mutex<Vec<Option<TrackMatch>>>> =
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::<Vec<()>>()
.await;
// Extract results in order
let locked = results.lock().await;
let ordered: Vec<TrackMatch> = 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::<Vec<_>>()
.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);
}
}
+72
View File
@@ -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<String>,
pub duration_ms: Option<u64>,
pub isrc: Option<String>,
pub provider_id: Option<String>,
pub provider_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportPlaylist {
pub provider: ImportProvider,
pub provider_id: String,
pub name: String,
pub description: Option<String>,
pub tracks: Vec<ImportTrack>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrackMatch {
pub source: ImportTrack,
pub qobuz_track_id: Option<u64>,
pub qobuz_title: Option<String>,
pub qobuz_artist: Option<String>,
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<u64>,
pub parts_created: u32,
pub matches: Vec<TrackMatch>,
}
#[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<String>,
}
@@ -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<super::MusicResource> {
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<ImportPlaylist, PlaylistImportError> {
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<String> {
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>")? + script_start;
let raw = &html[script_start..script_end];
Some(unescape_basic(raw))
}
fn find_track_items(data: &Value) -> Option<Vec<&Value>> {
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<String> {
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("&quot;", "\"")
.replace("&#34;", "\"")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
}
#[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!(
"<script type=\"application/json\" id=\"serialized-server-data\">",
"[{&quot;itemKind&quot;:&quot;trackLockup&quot;}]",
"</script>"
);
assert_eq!(
extract_script(html, "serialized-server-data").as_deref(),
Some("[{\"itemKind\":\"trackLockup\"}]")
);
}
#[test]
fn extract_meta_reads_og_tags() {
let html = concat!(
"<meta property=\"og:title\" content=\"My Playlist &amp; More\">",
"<meta property=\"og:description\" content=\"\">"
);
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("&quot;a&quot; &#34;b&#34; &amp; &lt;c&gt;"),
"\"a\" \"b\" & <c>"
);
}
#[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());
}
}
@@ -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<super::MusicResource> {
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<String> {
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<ImportPlaylist, PlaylistImportError> {
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);
}
}
@@ -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<MusicResource> {
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<ProviderKind, PlaylistImportError> {
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<ImportPlaylist, PlaylistImportError> {
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
})
);
}
}
@@ -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<super::MusicResource> {
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/<id> or spotify:track:<id>
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/<id> or spotify:album:<id>
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<String> {
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::<Vec<_>>()
.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<ImportPlaylist, PlaylistImportError> {
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<ImportPlaylist, PlaylistImportError> {
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<String> {
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>")? + 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!(
"<html><head></head><body>",
"<script id=\"__NEXT_DATA__\" type=\"application/json\">",
"{\"props\":{\"pageProps\":{\"state\":{\"data\":{\"entity\":{\"title\":\"My Mix\"}}}}}}",
"</script></body></html>"
);
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("<html></html>", "__NEXT_DATA__"), None);
}
}
@@ -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<super::MusicResource> {
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<String> {
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<ImportPlaylist, PlaylistImportError> {
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<Vec<String>, 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<Vec<ImportTrack>, 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<u64> {
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<String, PlaylistImportError> {
// 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);
}
}
+47
View File
@@ -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<F: Fn(ImportEvent) + Send + Sync> ImportProgressSink for F {
fn emit(&self, event: ImportEvent) {
self(event)
}
}
+1
View File
@@ -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" }
File diff suppressed because it is too large. Load diff
+486
View File
@@ -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<ImportPlaylist>,
/// 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<ProviderKey>,
/// 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<Mutex<Session>> = 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::<PlaylistImportState>();
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::<ImportLogEntry>::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::<SidebarState>().get_folders();
let mut opts: Vec<slint::SharedString> = vec!["No folder".into()];
let mut ids: Vec<slint::SharedString> = 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::<PlaylistImportState>();
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::<ImportLogEntry>::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<String> {
let state = window.global::<PlaylistImportState>();
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::<ImportLogEntry>::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::<PlaylistImportState>();
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::<PlaylistImportState>();
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<String>,
/// 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<ExecuteArgs> {
let state = window.global::<PlaylistImportState>();
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::<PlaylistImportState>();
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::<PlaylistImportState>();
{
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::<PlaylistImportState>();
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<AppWindow>,
generation: u64,
}
impl SlintSink {
pub fn new(weak: slint::Weak<AppWindow>, 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::<PlaylistImportState>();
let log = state.get_log();
let entry = ImportLogEntry {
message: message.into(),
status: status.into(),
};
if let Some(vec) = log.as_any().downcast_ref::<VecModel<ImportLogEntry>>() {
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<ImportLogEntry> = log.iter().collect();
entries.push(entry);
state.set_log(ModelRc::new(VecModel::from(entries)));
}
}
fn clear_summary(window: &AppWindow) {
let state = window.global::<PlaylistImportState>();
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");
}
}
+10 -1
View File
@@ -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::<slint::SharedString>::new())));
state.set_playlists(ModelRc::new(VecModel::from(Vec::<PlaylistPickItem>::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<slint::SharedString> = 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::<PlaylistPickItem>::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<PickPlaylist>) {
let items: Vec<PlaylistPickItem> = 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<PickPlaylist>) {
"".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::<PlaylistPickerState>();
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);
}
+11 -1
View File
@@ -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 <string> 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 <AppScreen> screen: AppScreen.splash;
// Forwarded to the Rust layer.
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg xmlns="http://www.w3.org/2000/svg"
aria-label="Apple Music" role="img"
viewBox="0 0 512 512"><rect
width="512" height="512"
rx="15%" fill="url(#g)"/><linearGradient id="g" x1=".5" y1=".99" x2=".5" y2=".02"><stop offset="0" stop-color="#FA233B"/><stop offset="1" stop-color="#FB5C74"/></linearGradient><path fill="#ffffff" d="M199 359V199q0-9 10-11l138-28q11-2 12 10v122q0 15-45 20c-57 9-48 105 30 79 30-11 35-40 35-69V88s0-20-17-15l-170 35s-13 2-13 18v203q0 15-45 20c-57 9-48 105 30 79 30-11 35-40 35-69"/></svg>

After

Width:  |  Height:  |  Size: 636 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg xmlns="http://www.w3.org/2000/svg"
aria-label="Deezer" role="img"
fill="none" stroke-width="20"
viewBox="0 0 512 512"><rect
width="512" height="512"
rx="15%"
fill="#231f20"/><path stroke="#f1796e" d="M78 241h64m-64 26h64"/><path stroke="#ec2529" d="M78 293h64m-64 26h64m-64 26h64"/><path stroke="#f6dd05" d="M148 293h64m-64 26h64m-64 26h64"/><path stroke="#e78dcc" d="M218 241h64m-64 26h64m-64-52h64m-64-26h64"/><path stroke="#d81996" d="M218 293h64m-64 26h64m-64 26h64"/><path stroke="#d6e465" d="M288 241h64m-64 26h64"/><path stroke="#adda1a" d="M288 293h64m-64 26h64m-64 26h64"/><path stroke="#b0d8e6" d="M358 241h64m-64 26h64m-64-52h64m-64-26h64m-64-26h64"/><path stroke="#56bbe9" d="M358 293h64m-64 26h64m-64 26h64"/></svg>

After

Width:  |  Height:  |  Size: 852 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3v12" />
<path d="m8 11 4 4 4-4" />
<path d="M8 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-4" />
</svg>

After

Width:  |  Height:  |  Size: 326 B

@@ -0,0 +1,2 @@
<?xml version="1.0" standalone="no"?>
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 192 192" fill="none"><path stroke="#000000" stroke-linecap="round" stroke-width="8" d="M140.672 157.486a76.004 76.004 0 0 1-102.464-12.128 76.001 76.001 0 0 1 107.15-107.15 76.004 76.004 0 0 1 12.128 102.464"/><path stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="15" stroke-width="8" d="M124.807 142.105a54.364 54.364 0 1 1 17.296-17.296"/><path stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="15" stroke-width="8" d="M95.997 117.193c11.705 0 21.193-9.488 21.193-21.193 0-11.704-9.488-21.193-21.193-21.193-11.704 0-21.193 9.489-21.193 21.193 0 11.705 9.489 21.193 21.193 21.193Z"/><path fill="#000000" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="15" stroke-width="3.799" d="M95.997 98.103a2.103 2.103 0 1 0 0-4.205 2.103 2.103 0 0 0 0 4.205Z"/><path fill="#000000" d="m121.193 121.196 41.871 41.625Z"/><path stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="15" stroke-width="12" d="m121.193 121.196 41.871 41.625"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg xmlns="http://www.w3.org/2000/svg"
aria-label="Spotify" role="img"
viewBox="0 0 512 512">
<rect
width="512" height="512"
rx="15%"
fill="#3bd75f"/>
<circle cx="256" cy="256" fill="#ffffff" r="192"/>
<g fill="none" stroke="#3bd75f" stroke-linecap="round">
<path d="m141 195c75-20 164-15 238 24" stroke-width="36"/>
<path d="m152 257c61-17 144-13 203 24" stroke-width="31"/>
<path d="m156 315c54-12 116-17 178 20" stroke-width="24"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 576 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path d="M16.016 5.323l-5.339 5.339-5.339-5.339-5.339 5.339 5.339 5.339 5.339-5.339 5.339 5.339-5.339 5.339 5.339 5.339 5.339-5.339-5.339-5.339 5.339-5.339zM21.391 10.661l5.302-5.307 5.307 5.307-5.307 5.307z"/>
</svg>

After

Width:  |  Height:  |  Size: 443 B

@@ -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<u64> + 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();
}
}
}
}
}
}
}
@@ -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();
}
}
}
}
}
}
}
}
@@ -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 <PlaylistPickItem> item;
in property <int> 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 <bool> highlighted;
// Enter-to-select dispatch: when `select-seq` bumps, the row whose
// filter-rank equals `select-rank` fires `select()`.
in property <int> select-seq;
in property <int> 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 <bool> dropdown-open: false;
property <string> selected-id: ""; // "" = nothing selected
property <string> 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 <int> highlight: -1;
// Bumped by Enter; the rank-matching DropRow selects itself.
property <int> select-seq: 0;
property <int> nav-count: Math.min(PlaylistPickerState.filter-matches, 8) + 1;
property <int> 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 <int> 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 <length> 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;
}
}
}
}
+17 -1
View File
@@ -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%;
+19 -3
View File
@@ -25,10 +25,14 @@ component MenuItem inherits Rectangle {
in property <bool> selected: false;
in property <image> icon;
in property <bool> has-icon: false;
// When false the row is muted and ignores clicks (Import while
// offline). Same pattern as ContextMenuItem's enabled.
in property <bool> 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");
+173 -1
View File
@@ -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 <string> artwork-url;
in property <bool> has-custom-cover: false;
in property <string> 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 <string> meta-pre;
in property <string> meta-post;
in property <string> quality-tier;
// "24-bit / 96 kHz" — quality-badge detail line.
in property <string> quality-detail;
@@ -295,6 +301,10 @@ export global AlbumState {
in property <string> description;
// Truncated description for the header; full text lives in the modal.
in property <string> 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 <string> description-shorter;
in property <image> artwork;
// Artwork-derived tint for the header gradient.
in property <color> header-color: #181820;
@@ -302,6 +312,25 @@ export global AlbumState {
// Label id for the sidebar label card → label page navigation ("" = none).
in property <string> 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 <bool> 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 <bool> 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 <bool> is-favorite: false;
// True while a favorite toggle is in flight (spinner on the heart).
in property <bool> 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 <bool> multi-select: false;
// Number of selected rows (drives the bulk bar count + select-all label).
in property <int> selected-count: 0;
in property <[TrackItem]> tracks: [];
in property <bool> 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 <color> 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 <bool> top-tracks-multi-select: false;
in property <int> 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 <bool> queue-open: false;
in-out property <bool> 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 <length> 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 <bool> 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 <bool> nav-in-sidebar: true;
@@ -3067,6 +3128,11 @@ export struct PlaylistPickItem {
// LOCAL playlist (library.db entity, id "local:<uuid>") — 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 <bool> 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 <string> filter: "";
// Total rows matching the current filter — drives the dropdown's
// no-results/overflow rows and its explicit height.
in property <int> 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 <bool> creating-open: false;
in-out property <string> create-name: "";
in-out property <bool> 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<u64> 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 <bool> open: false;
in property <int> duplicate-count: 0;
in property <int> total-count: 0;
in property <bool> busy: false;
in property <string> 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 <bool> open: false;
in-out property <string> url;
// Rename field — prefilled with the fetched playlist name.
in-out property <string> custom-name;
// True during preview fetch AND import execute.
in property <bool> loading: false;
// "" = none; shown as the red banner at the top of the body.
in property <string> error: "";
// "" | "spotify" | "apple" | "tidal" | "deezer" — the locked-or-
// detected provider; its source logo renders full-opacity.
in property <string> active-provider: "";
// Rust-computed: provider detected && !offline.
in property <bool> 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 <bool> show-preview: false;
in property <bool> import-completed: false;
// The "Conversion progress" panel (any log lines, or loading).
in property <bool> progress-visible: false;
// Bar + status/current-track lines visible.
in property <bool> has-progress: false;
in property <float> progress: 0.0; // 0..1
in property <string> status-line: "";
in property <string> 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 <int> folder-index: 0;
// Summary block, pre-formatted lines ("" = hidden).
in property <string> summary-playlist: "";
in property <string> summary-matched: "";
in property <string> summary-skipped: "";
in property <string> 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();
}