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:
33 files changed
+5664
-129
No files matched your search
Generated
+16
@@ -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",
|
||||
|
||||
@@ -25,6 +25,7 @@ members = [
|
||||
"qbz-credentials",
|
||||
"qbz-media-controls",
|
||||
"qbz-mixtape",
|
||||
"qbz-playlist-import",
|
||||
"qbz-lyrics",
|
||||
]
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(""", "\"")
|
||||
.replace(""", "\"")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_playlist_id_table() {
|
||||
// Editorial pl. id
|
||||
assert_eq!(
|
||||
parse_playlist_id(
|
||||
"https://music.apple.com/us/playlist/top-100-global/pl.d25f5d1181894928af76c85c967f8f31"
|
||||
),
|
||||
Some((
|
||||
"us".to_string(),
|
||||
"pl.d25f5d1181894928af76c85c967f8f31".to_string()
|
||||
))
|
||||
);
|
||||
// User pl.u- id + query strip
|
||||
assert_eq!(
|
||||
parse_playlist_id("https://music.apple.com/mx/playlist/mias/pl.u-abc123?l=en"),
|
||||
Some(("mx".to_string(), "pl.u-abc123".to_string())),
|
||||
);
|
||||
// Album URL is not a playlist
|
||||
assert_eq!(
|
||||
parse_playlist_id("https://music.apple.com/us/album/abbey-road/1441164426"),
|
||||
None
|
||||
);
|
||||
// Too few path segments
|
||||
assert_eq!(
|
||||
parse_playlist_id("https://music.apple.com/us/playlist"),
|
||||
None
|
||||
);
|
||||
// Wrong host
|
||||
assert_eq!(
|
||||
parse_playlist_id("https://example.com/us/playlist/x/pl.123"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_resource_song_album_track() {
|
||||
assert!(matches!(
|
||||
detect_resource("https://music.apple.com/us/song/hey-jude/1441164589"),
|
||||
Some(super::super::MusicResource::Track { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
detect_resource("https://music.apple.com/us/album/abbey-road/1441164426?i=1441164589"),
|
||||
Some(super::super::MusicResource::Track { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
detect_resource("https://music.apple.com/us/album/abbey-road/1441164426"),
|
||||
Some(super::super::MusicResource::Album { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
detect_resource("https://music.apple.com/us/playlist/x/pl.123"),
|
||||
Some(super::super::MusicResource::Playlist { .. })
|
||||
));
|
||||
assert_eq!(detect_resource("https://example.com/us/album/x/1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_script_unescapes_serialized_server_data() {
|
||||
let html = concat!(
|
||||
"<script type=\"application/json\" id=\"serialized-server-data\">",
|
||||
"[{"itemKind":"trackLockup"}]",
|
||||
"</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 & 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(""a" "b" & <c>"),
|
||||
"\"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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
+995
-12
File diff suppressed because it is too large.
Load diff
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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%;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user