feat(playlist-import): integrate Codex playlist import feature

Cherry-pick and integrate playlist import from playlist-import-impl branch:
- Add playlist_import Rust module with providers (Spotify, Apple, Tidal, Deezer)
- Add track matching algorithm (ISRC first, then fuzzy matching)
- Add PlaylistImportModal.svelte with progress log UI
- Wire up import button in Sidebar
- Add modal state management in uiStore
- Register Tauri commands for preview and execute

Original implementation by Codex, integrated manually due to branch divergence.
This commit is contained in:
vicrodh committed 2026-01-11 17:42:52 -06:00
1 parent 4b7e6b9c17
commit 22cf6d9051
17 files changed
+1932 -2

No files matched your search

+2
View File
@@ -9,6 +9,7 @@ pub mod lastfm;
pub mod notification;
pub mod playback;
pub mod playlist;
pub mod playlist_import;
pub mod queue;
pub mod search;
pub mod share;
@@ -20,6 +21,7 @@ pub use lastfm::*;
pub use notification::*;
pub use playback::*;
pub use playlist::*;
pub use playlist_import::*;
pub use queue::*;
pub use search::*;
pub use share::*;
+35
View File
@@ -0,0 +1,35 @@
//! Playlist import commands
use tauri::State;
use crate::playlist_import::{import_public_playlist, preview_public_playlist, ImportPlaylist, ImportSummary};
use crate::AppState;
#[tauri::command]
pub async fn playlist_import_preview(url: String) -> Result<ImportPlaylist, String> {
log::info!("Command: playlist_import_preview {}", url);
preview_public_playlist(&url)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn playlist_import_execute(
url: String,
name_override: Option<String>,
is_public: Option<bool>,
state: State<'_, AppState>,
) -> Result<ImportSummary, String> {
log::info!("Command: playlist_import_execute {}", url);
let client = state.client.lock().await;
import_public_playlist(
&url,
&client,
name_override.as_deref(),
is_public.unwrap_or(false),
)
.await
.map_err(|e| e.to_string())
}
+4
View File
@@ -17,6 +17,7 @@ pub mod library;
pub mod lyrics;
pub mod media_controls;
pub mod player;
pub mod playlist_import;
pub mod queue;
pub mod reco_store;
pub mod session_store;
@@ -263,6 +264,9 @@ pub fn run() {
commands::add_tracks_to_playlist,
commands::remove_tracks_from_playlist,
commands::update_playlist,
// Playlist import commands
commands::playlist_import_preview,
commands::playlist_import_execute,
// Favorites commands
commands::get_favorites,
commands::add_favorite,
+19
View File
@@ -0,0 +1,19 @@
//! Errors for playlist import
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PlaylistImportError {
#[error("Invalid playlist URL: {0}")]
InvalidUrl(String),
#[error("Provider not supported: {0}")]
UnsupportedProvider(String),
#[error("Missing credentials: {0}")]
MissingCredentials(String),
#[error("HTTP error: {0}")]
Http(String),
#[error("Parse error: {0}")]
Parse(String),
#[error("Qobuz error: {0}")]
Qobuz(String),
}
+72
View File
@@ -0,0 +1,72 @@
//! Orchestrates playlist import
use crate::api::QobuzClient;
use crate::playlist_import::errors::PlaylistImportError;
use crate::playlist_import::match_qobuz::match_tracks;
use crate::playlist_import::models::{ImportPlaylist, ImportSummary};
use crate::playlist_import::providers::{detect_provider, fetch_playlist};
const ADD_CHUNK_SIZE: usize = 50;
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,
) -> Result<ImportSummary, PlaylistImportError> {
let playlist = preview_public_playlist(url).await?;
let matches = match_tracks(client, &playlist.tracks).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_id = None;
if !matched_track_ids.is_empty() {
let name = name_override.unwrap_or(&playlist.name);
let description = playlist
.description
.clone()
.or_else(|| Some(format!("Imported from {}", playlist.provider.as_str())));
let created = client
.create_playlist(name, description.as_deref(), is_public)
.await
.map_err(|e| PlaylistImportError::Qobuz(e.to_string()))?;
qobuz_playlist_id = Some(created.id);
for chunk in matched_track_ids.chunks(ADD_CHUNK_SIZE) {
client
.add_tracks_to_playlist(created.id, chunk)
.await
.map_err(|e| PlaylistImportError::Qobuz(e.to_string()))?;
}
}
Ok(ImportSummary {
provider: playlist.provider,
playlist_name: playlist.name,
total_tracks,
matched_tracks: matched_count,
skipped_tracks,
qobuz_playlist_id,
matches,
})
}
@@ -0,0 +1,218 @@
//! Match imported tracks to Qobuz catalog
use crate::api::models::Track;
use crate::api::QobuzClient;
use crate::playlist_import::errors::PlaylistImportError;
use crate::playlist_import::models::{ImportTrack, TrackMatch};
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;
pub async fn match_tracks(
client: &QobuzClient,
tracks: &[ImportTrack],
) -> Result<Vec<TrackMatch>, PlaylistImportError> {
let mut results = Vec::new();
for track in tracks {
let query = format!("{} {}", track.artist, track.title);
let search = client
.search_tracks(&query, SEARCH_LIMIT, 0)
.await
.map_err(|e| PlaylistImportError::Qobuz(e.to_string()))?;
let (best, score) = select_best_match(track, &search.items);
let match_entry = match best {
Some(candidate) if score >= MIN_SCORE => 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,
},
};
results.push(match_entry);
}
Ok(results)
}
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
}
+11
View File
@@ -0,0 +1,11 @@
//! Public playlist import
pub mod errors;
pub mod importer;
pub mod match_qobuz;
pub mod models;
pub mod providers;
pub use errors::PlaylistImportError;
pub use importer::{import_public_playlist, preview_public_playlist};
pub use models::{ImportPlaylist, ImportProvider, ImportSummary, ImportTrack, TrackMatch};
+62
View File
@@ -0,0 +1,62 @@
//! 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_id: Option<u64>,
pub matches: Vec<TrackMatch>,
}
@@ -0,0 +1,156 @@
//! Apple Music playlist import
use serde_json::Value;
use crate::playlist_import::errors::PlaylistImportError;
use crate::playlist_import::models::{ImportPlaylist, ImportProvider, ImportTrack};
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 = reqwest::get(&url)
.await
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
.text()
.await
.map_err(|e| PlaylistImportError::Http(e.to_string()))?;
let name = extract_meta(&html, "og:title").unwrap_or_else(|| "Apple Music Playlist".to_string());
let description = extract_meta(&html, "og:description").filter(|v| !v.is_empty());
let json_text = extract_script(&html, "serialized-server-data")
.ok_or_else(|| PlaylistImportError::Parse("Apple Music serialized-server-data not found".to_string()))?;
let data: Value = serde_json::from_str(&json_text)
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
let items = find_track_items(&data)
.ok_or_else(|| PlaylistImportError::Parse("Apple Music track list not found".to_string()))?;
let mut tracks = Vec::new();
for item in items {
let title = item
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string();
let artist = item
.get("artistName")
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string();
let duration_ms = item
.get("duration")
.and_then(|v| v.as_u64());
let provider_id = item
.get("contentDescriptor")
.and_then(|v| v.get("identifiers"))
.and_then(|v| v.get("storeAdamID"))
.and_then(|v| v.as_str())
.map(|v| v.to_string());
let provider_url = item
.get("contentDescriptor")
.and_then(|v| v.get("url"))
.and_then(|v| v.as_str())
.map(|v| v.to_string());
let album = item
.get("tertiaryLinks")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.get("title"))
.and_then(|v| v.as_str())
.map(|v| v.to_string());
tracks.push(ImportTrack {
title,
artist,
album,
duration_ms,
isrc: None,
provider_id,
provider_url,
});
}
Ok(ImportPlaylist {
provider: ImportProvider::AppleMusic,
provider_id: playlist_id.to_string(),
name,
description,
tracks,
})
}
fn extract_script(html: &str, id: &str) -> Option<String> {
let marker = format!("id=\"{}\"", id);
let start = html.find(&marker)?;
let script_start = html[start..].find('>')? + start + 1;
let script_end = html[script_start..].find("</script>")? + script_start;
let raw = &html[script_start..script_end];
Some(unescape_basic(raw))
}
fn find_track_items(data: &Value) -> Option<Vec<&Value>> {
match data {
Value::Object(map) => {
if map.get("itemKind").and_then(|v| v.as_str()) == Some("trackLockup") {
let items = map.get("items").and_then(|v| v.as_array())?;
if !items.is_empty() {
return Some(items.iter().collect());
}
}
for value in map.values() {
if let Some(found) = find_track_items(value) {
return Some(found);
}
}
}
Value::Array(list) => {
for value in list {
if let Some(found) = find_track_items(value) {
return Some(found);
}
}
}
_ => {}
}
None
}
fn extract_meta(html: &str, property: &str) -> Option<String> {
let needle = format!("property=\"{}\"", property);
let start = html.find(&needle)?;
let content_start = html[start..].find("content=\"")? + start + "content=\"".len();
let content_end = html[content_start..].find('"')? + content_start;
Some(unescape_basic(&html[content_start..content_end]))
}
fn unescape_basic(input: &str) -> String {
input
.replace("&quot;", "\"")
.replace("&#34;", "\"")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
}
@@ -0,0 +1,105 @@
//! Deezer playlist import
use serde_json::Value;
use crate::playlist_import::errors::PlaylistImportError;
use crate::playlist_import::models::{ImportPlaylist, ImportProvider, ImportTrack};
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 = reqwest::get(&url)
.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,
})
}
@@ -0,0 +1,45 @@
//! Provider implementations
pub mod apple;
pub mod deezer;
pub mod spotify;
pub mod tidal;
use crate::playlist_import::errors::PlaylistImportError;
use crate::playlist_import::models::ImportPlaylist;
#[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()))
}
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
}
ProviderKind::Tidal { playlist_id } => tidal::fetch_playlist(&playlist_id).await,
ProviderKind::Deezer { playlist_id } => deezer::fetch_playlist(&playlist_id).await,
}
}
@@ -0,0 +1,301 @@
//! Spotify playlist import
use std::env;
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use serde_json::Value;
use crate::playlist_import::errors::PlaylistImportError;
use crate::playlist_import::models::{ImportPlaylist, ImportProvider, ImportTrack};
const SPOTIFY_TOKEN_URL: &str = "https://accounts.spotify.com/api/token";
const SPOTIFY_API_BASE: &str = "https://api.spotify.com/v1";
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
}
pub async fn fetch_playlist(playlist_id: &str) -> Result<ImportPlaylist, PlaylistImportError> {
if let Ok(token) = get_app_token().await {
if let Ok(playlist) = fetch_playlist_with_token(playlist_id, &token).await {
return Ok(playlist);
}
}
fetch_playlist_from_embed(playlist_id).await
}
async fn fetch_playlist_with_token(
playlist_id: &str,
access_token: &str,
) -> Result<ImportPlaylist, PlaylistImportError> {
let client = reqwest::Client::new();
let meta_url = format!("{}/playlists/{}", SPOTIFY_API_BASE, playlist_id);
let meta: Value = client
.get(&meta_url)
.header("Authorization", format!("Bearer {}", access_token))
.query(&[("fields", "name,description,tracks.total")])
.send()
.await
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
.json()
.await
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
let name = meta
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Spotify Playlist")
.to_string();
let description = meta
.get("description")
.and_then(|v| v.as_str())
.map(|v| v.to_string())
.filter(|v| !v.is_empty());
let mut tracks = Vec::new();
let mut offset = 0u32;
let limit = 100u32;
loop {
let tracks_url = format!("{}/playlists/{}/tracks", SPOTIFY_API_BASE, playlist_id);
let response: Value = client
.get(&tracks_url)
.header("Authorization", format!("Bearer {}", access_token))
.query(&[
("limit", limit.to_string()),
("offset", offset.to_string()),
("fields", "items(track(name,artists(name),album(name),duration_ms,external_ids,id,external_urls)),next".to_string()),
])
.send()
.await
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
.json()
.await
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
let items = response
.get("items")
.and_then(|v| v.as_array())
.ok_or_else(|| PlaylistImportError::Parse("Spotify tracks missing items".to_string()))?;
for item in items {
let track = match item.get("track") {
Some(v) if !v.is_null() => v,
_ => continue,
};
let title = track
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string();
let artist = join_artists(track.get("artists"));
let album = track
.get("album")
.and_then(|v| v.get("name"))
.and_then(|v| v.as_str())
.map(|v| v.to_string());
let duration_ms = track
.get("duration_ms")
.and_then(|v| v.as_u64());
let isrc = track
.get("external_ids")
.and_then(|v| v.get("isrc"))
.and_then(|v| v.as_str())
.map(|v| v.to_string());
let provider_id = track
.get("id")
.and_then(|v| v.as_str())
.map(|v| v.to_string());
let provider_url = track
.get("external_urls")
.and_then(|v| v.get("spotify"))
.and_then(|v| v.as_str())
.map(|v| v.to_string());
tracks.push(ImportTrack {
title,
artist,
album,
duration_ms,
isrc,
provider_id,
provider_url,
});
}
let has_next = response
.get("next")
.and_then(|v| v.as_str())
.map(|v| !v.is_empty())
.unwrap_or(false);
if !has_next {
break;
}
offset += limit;
}
Ok(ImportPlaylist {
provider: ImportProvider::Spotify,
provider_id: playlist_id.to_string(),
name,
description,
tracks,
})
}
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 = reqwest::get(&url)
.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,
});
}
Ok(ImportPlaylist {
provider: ImportProvider::Spotify,
provider_id: playlist_id.to_string(),
name,
description: None,
tracks,
})
}
async fn get_app_token() -> Result<String, PlaylistImportError> {
let client_id = env::var("SPOTIFY_API_CLIENT_ID")
.map_err(|_| PlaylistImportError::MissingCredentials("SPOTIFY_API_CLIENT_ID".to_string()))?;
let client_secret = env::var("SPOTIFY_API_CLIENT_SECRET")
.map_err(|_| PlaylistImportError::MissingCredentials("SPOTIFY_API_CLIENT_SECRET".to_string()))?;
let auth = STANDARD.encode(format!("{}:{}", client_id, client_secret));
let response: Value = reqwest::Client::new()
.post(SPOTIFY_TOKEN_URL)
.header("Authorization", format!("Basic {}", auth))
.header("Content-Type", "application/x-www-form-urlencoded")
.body("grant_type=client_credentials")
.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("Spotify token missing access_token".to_string()))
}
fn join_artists(value: Option<&Value>) -> String {
let artists = value
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|a| a.get("name").and_then(|v| v.as_str()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
if artists.is_empty() {
"Unknown".to_string()
} else {
artists.join(", ")
}
}
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())
}
@@ -0,0 +1,326 @@
//! Tidal playlist import (OpenAPI v2)
use std::env;
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use serde_json::Value;
use crate::playlist_import::errors::PlaylistImportError;
use crate::playlist_import::models::{ImportPlaylist, ImportProvider, ImportTrack};
const TIDAL_TOKEN_URL: &str = "https://auth.tidal.com/v1/oauth2/token";
const TIDAL_API_BASE: &str = "https://openapi.tidal.com/v2";
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
}
pub async fn fetch_playlist(playlist_id: &str) -> Result<ImportPlaylist, PlaylistImportError> {
let token = get_app_token().await?;
let country_code = env::var("TIDAL_COUNTRY_CODE").unwrap_or_else(|_| "US".to_string());
let client = reqwest::Client::new();
let meta_url = format!("{}/playlists/{}", TIDAL_API_BASE, playlist_id);
let meta: Value = client
.get(&meta_url)
.header("Authorization", format!("Bearer {}", token))
.query(&[("countryCode", &country_code)])
.send()
.await
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
.json()
.await
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
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);
let mut request = client
.get(&url)
.header("Authorization", format!("Bearer {}", token));
if !next_path.contains("countryCode=") {
request = request.query(&[("countryCode", country_code)]);
}
let response: Value = request
.send()
.await
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
.json()
.await
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
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 = 50usize;
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);
let response: Value = 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()))?
.json()
.await
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
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> {
let client_id = env::var("TIDAL_API_CLIENT_ID")
.map_err(|_| PlaylistImportError::MissingCredentials("TIDAL_API_CLIENT_ID".to_string()))?;
let client_secret = env::var("TIDAL_API_CLIENT_SECRET")
.map_err(|_| PlaylistImportError::MissingCredentials("TIDAL_API_CLIENT_SECRET".to_string()))?;
let auth = STANDARD.encode(format!("{}:{}", client_id, client_secret));
let response: Value = reqwest::Client::new()
.post(TIDAL_TOKEN_URL)
.header("Authorization", format!("Basic {}", auth))
.header("Content-Type", "application/x-www-form-urlencoded")
.body("grant_type=client_credentials")
.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 token missing access_token".to_string()))
}
@@ -0,0 +1,522 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { X } from 'lucide-svelte';
import GlassSurface from './glass/GlassSurface.svelte';
import { showToast } from '$lib/stores/toastStore';
type ProviderKey = 'spotify' | 'apple' | 'tidal' | 'deezer';
interface ImportTrack {
title: string;
artist: string;
album?: string | null;
duration_ms?: number | null;
isrc?: string | null;
}
interface ImportPlaylist {
provider: 'Spotify' | 'AppleMusic' | 'Tidal' | 'Deezer';
name: string;
tracks: ImportTrack[];
}
interface ImportSummary {
provider: 'Spotify' | 'AppleMusic' | 'Tidal' | 'Deezer';
playlist_name: string;
total_tracks: number;
matched_tracks: number;
skipped_tracks: number;
qobuz_playlist_id?: number | null;
}
interface Props {
isOpen: boolean;
onClose: () => void;
onSuccess?: (summary: ImportSummary) => void;
}
let { isOpen, onClose, onSuccess }: Props = $props();
let url = $state('');
let loading = $state(false);
let error = $state<string | null>(null);
let summary = $state<ImportSummary | null>(null);
let lockedProvider = $state<ProviderKey | null>(null);
let logEntries = $state<{ message: string; status: 'info' | 'success' | 'error' }[]>([]);
const providers: { key: ProviderKey; label: string; logo: string; color: string }[] = [
{ key: 'spotify', label: 'Spotify', logo: '/spotify-logo.svg', color: '#1DB954' },
{ key: 'apple', label: 'Apple Music', logo: '/apple-music-logo.svg', color: '#fa233b' },
{ key: 'tidal', label: 'Tidal', logo: '/tidal-tidal.svg', color: '#ffffff' },
{ key: 'deezer', label: 'Deezer', logo: '/deezer-logo.svg', color: '#00c7f2' }
];
const detectedProvider = $derived(detectProvider(url));
const activeProvider = $derived(lockedProvider ?? detectedProvider);
const isValid = $derived(!!detectedProvider);
$effect(() => {
if (isOpen) {
url = '';
loading = false;
error = null;
summary = null;
lockedProvider = null;
logEntries = [];
}
});
function detectProvider(value: string): ProviderKey | null {
const trimmed = value.trim();
if (!trimmed) return null;
if (
trimmed.startsWith('spotify:playlist:') ||
trimmed.includes('open.spotify.com/playlist/') ||
trimmed.includes('open.spotify.com/embed/playlist/')
) {
return 'spotify';
}
if (trimmed.includes('music.apple.com/') && trimmed.includes('/playlist/')) {
return 'apple';
}
if (trimmed.includes('tidal.com/') && trimmed.includes('/playlist/')) {
return 'tidal';
}
if (trimmed.includes('deezer.com/') && trimmed.includes('/playlist/')) {
return 'deezer';
}
return null;
}
function pushLog(message: string, status: 'info' | 'success' | 'error' = 'info') {
logEntries = [...logEntries, { message, status }];
}
async function handleImport() {
if (!isValid || loading) return;
loading = true;
error = null;
summary = null;
lockedProvider = detectedProvider;
logEntries = [];
try {
pushLog('Checking playlist link...');
const preview = await invoke<ImportPlaylist>('playlist_import_preview', { url });
pushLog(`Found ${preview.tracks.length} tracks from ${formatProvider(preview.provider)}.`);
pushLog('Matching tracks in Qobuz...');
const result = await invoke<ImportSummary>('playlist_import_execute', {
url,
nameOverride: null,
isPublic: false
});
summary = result;
pushLog(`Imported ${result.matched_tracks} of ${result.total_tracks} tracks into QBZ.`, 'success');
if (result.qobuz_playlist_id) {
pushLog('Playlist created in Qobuz.', 'success');
} else {
pushLog('No matching tracks found.', 'error');
}
onSuccess?.(result);
if (result.matched_tracks > 0) {
showToast('Playlist imported', 'success');
}
} catch (err) {
error = String(err);
pushLog(`Import failed: ${error}`, 'error');
showToast('Playlist import failed', 'error');
} finally {
loading = false;
}
}
function formatProvider(provider: ImportPlaylist['provider'] | ImportSummary['provider']): string {
switch (provider) {
case 'AppleMusic':
return 'Apple Music';
case 'Spotify':
return 'Spotify';
case 'Tidal':
return 'Tidal';
case 'Deezer':
return 'Deezer';
default:
return 'Unknown';
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
} else if (e.key === 'Enter' && !e.shiftKey) {
handleImport();
}
}
</script>
{#if isOpen}
<div
class="modal-overlay"
onclick={onClose}
onkeydown={handleKeydown}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<GlassSurface rootClassName="modal playlist-import-modal" enableRipple={false} onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<div class="header-title">
<img src="/qobuz-logo.svg" alt="Qobuz" class="qobuz-logo" />
<h2>Import Playlist</h2>
</div>
<button class="close-btn" onclick={onClose}>
<X size={20} />
</button>
</div>
<div class="modal-body">
{#if error}
<div class="error-message">{error}</div>
{/if}
<div class="form-group">
<label for="playlist-url">Playlist URL</label>
<input
id="playlist-url"
type="text"
bind:value={url}
placeholder="https://open.spotify.com/playlist/..."
disabled={loading}
/>
</div>
<div class="sources">
<span class="sources-label">Allowed sources</span>
<div class="sources-logos">
{#each providers as provider}
<div class="provider" data-provider={provider.key}>
<img
src={provider.logo}
alt={provider.label}
class:active={activeProvider === provider.key}
class="provider-logo"
style={`--provider-color: ${provider.color}`}
/>
</div>
{/each}
</div>
</div>
{#if logEntries.length > 0}
<div class="progress-panel">
<div class="progress-header">
<span>Conversion progress</span>
{#if loading}
<span class="spinner" aria-hidden="true"></span>
{/if}
</div>
<ul class="progress-log">
{#each logEntries as entry}
<li class={`log-item ${entry.status}`}>{entry.message}</li>
{/each}
</ul>
{#if summary}
<div class="summary">
<div class="summary-title">Summary</div>
<div class="summary-row">Playlist: {summary.playlist_name}</div>
<div class="summary-row">Tracks matched: {summary.matched_tracks} / {summary.total_tracks}</div>
<div class="summary-row">Skipped: {summary.skipped_tracks}</div>
</div>
{/if}
</div>
{/if}
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick={onClose} disabled={loading}>Close</button>
<button class="btn-primary" onclick={handleImport} disabled={!isValid || loading}>
{#if loading}
Importing...
{:else}
Import playlist
{/if}
</button>
</div>
</GlassSurface>
</div>
{/if}
<style>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
:global(.playlist-import-modal) {
width: 100%;
max-width: 560px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
--glass-bg: rgba(30, 30, 35, 0.9);
--glass-blur: 24px;
--glass-radius: 16px;
--glass-border: rgba(255, 255, 255, 0.1);
--glass-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--bg-tertiary);
}
.header-title {
display: flex;
align-items: center;
gap: 12px;
}
.header-title h2 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.qobuz-logo {
width: 26px;
height: 26px;
object-fit: contain;
}
.close-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
transition: color 150ms ease;
}
.close-btn:hover {
color: var(--text-primary);
}
.modal-body {
padding: 24px;
overflow-y: auto;
}
.error-message {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
padding: 12px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 16px;
}
.form-group {
margin-bottom: 12px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 8px;
}
.form-group input[type="text"] {
width: 100%;
padding: 10px 12px;
background: var(--bg-secondary);
border: 1px solid var(--bg-tertiary);
border-radius: 8px;
font-size: 14px;
color: var(--text-primary);
transition: border-color 150ms ease;
}
.form-group input[type="text"]:focus {
outline: none;
border-color: var(--accent-primary);
}
.sources {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
}
.sources-label {
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.sources-logos {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.provider-logo {
width: 70px;
height: 24px;
object-fit: contain;
filter: grayscale(1) brightness(0.7);
opacity: 0.5;
transition: filter 200ms ease, opacity 200ms ease, transform 200ms ease, box-shadow 200ms ease;
}
.provider-logo.active {
filter: drop-shadow(0 6px 14px var(--provider-color));
opacity: 1;
transform: translateY(-1px) scale(1.02);
}
.progress-panel {
margin-top: 12px;
padding: 16px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.progress-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 10px;
}
.progress-log {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.log-item {
font-size: 13px;
color: var(--text-muted);
}
.log-item.success {
color: #34d399;
}
.log-item.error {
color: #f87171;
}
.summary {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.summary-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
}
.summary-row {
font-size: 12px;
color: var(--text-muted);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px 20px;
border-top: 1px solid var(--bg-tertiary);
}
.btn-secondary,
.btn-primary {
padding: 10px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: transform 150ms ease, background 150ms ease, opacity 150ms ease;
}
.btn-secondary {
background: transparent;
border: 1px solid var(--bg-tertiary);
color: var(--text-secondary);
}
.btn-primary {
background: var(--accent-primary);
border: none;
color: var(--text-on-accent);
}
.btn-primary:disabled,
.btn-secondary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.spinner {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: var(--accent-primary);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 720px) {
:global(.playlist-import-modal) {
max-width: calc(100% - 24px);
}
.sources-logos {
gap: 12px;
}
.provider-logo {
width: 56px;
height: 20px;
}
}
</style>
+6 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Search, Home, HardDrive, Music, Disc3, Mic2, Plus, RefreshCw, ChevronDown, ChevronUp, Heart, ListMusic } from 'lucide-svelte';
import { Search, Home, HardDrive, Music, Disc3, Mic2, Plus, RefreshCw, ChevronDown, ChevronUp, Heart, ListMusic, Download } from 'lucide-svelte';
import { invoke } from '@tauri-apps/api/core';
import { onMount } from 'svelte';
import NavigationItem from './NavigationItem.svelte';
@@ -17,6 +17,7 @@
onNavigate: (view: string) => void;
onPlaylistSelect?: (playlistId: number) => void;
onCreatePlaylist?: () => void;
onImportPlaylist?: () => void;
onSettingsClick?: () => void;
onAboutClick?: () => void;
onLogout?: () => void;
@@ -30,6 +31,7 @@
onNavigate,
onPlaylistSelect,
onCreatePlaylist,
onImportPlaylist,
onSettingsClick,
onAboutClick,
onLogout,
@@ -124,6 +126,9 @@
<button class="icon-btn" onclick={onCreatePlaylist} title="New playlist">
<Plus size={14} />
</button>
<button class="icon-btn" onclick={onImportPlaylist} title="Import playlist">
<Download size={14} />
</button>
<button class="icon-btn" onclick={loadUserPlaylists} title="Refresh playlists">
<RefreshCw size={14} />
</button>
+24 -1
View File
@@ -14,6 +14,7 @@ let isCastPickerOpen = false;
let isPlaylistModalOpen = false;
let playlistModalMode: 'create' | 'edit' | 'addTrack' = 'create';
let playlistModalTrackIds: number[] = [];
let isPlaylistImportOpen = false;
// Listeners
const listeners = new Set<() => void>();
@@ -144,6 +145,22 @@ export function closePlaylistModal(): void {
notifyListeners();
}
// ============ Playlist Import Modal ============
export function getPlaylistImportOpen(): boolean {
return isPlaylistImportOpen;
}
export function openPlaylistImport(): void {
isPlaylistImportOpen = true;
notifyListeners();
}
export function closePlaylistImport(): void {
isPlaylistImportOpen = false;
notifyListeners();
}
// ============ Escape Key Handler ============
/**
@@ -171,6 +188,10 @@ export function handleEscapeKey(): boolean {
closePlaylistModal();
return true;
}
if (isPlaylistImportOpen) {
closePlaylistImport();
return true;
}
return false;
}
@@ -184,6 +205,7 @@ export interface UIState {
isPlaylistModalOpen: boolean;
playlistModalMode: 'create' | 'edit' | 'addTrack';
playlistModalTrackIds: number[];
isPlaylistImportOpen: boolean;
}
export function getUIState(): UIState {
@@ -194,6 +216,7 @@ export function getUIState(): UIState {
isCastPickerOpen,
isPlaylistModalOpen,
playlistModalMode,
playlistModalTrackIds
playlistModalTrackIds,
isPlaylistImportOpen
};
}
+24
View File
@@ -39,6 +39,8 @@
closeCastPicker,
openPlaylistModal,
closePlaylistModal,
openPlaylistImport,
closePlaylistImport,
handleEscapeKey as handleUIEscape,
getUIState,
type UIState
@@ -218,6 +220,7 @@
import ExpandedPlayer from '$lib/components/ExpandedPlayer.svelte';
import FocusMode from '$lib/components/FocusMode.svelte';
import PlaylistModal from '$lib/components/PlaylistModal.svelte';
import PlaylistImportModal from '$lib/components/PlaylistImportModal.svelte';
import CastPicker from '$lib/components/CastPicker.svelte';
import LyricsSidebar from '$lib/components/lyrics/LyricsSidebar.svelte';
@@ -242,6 +245,7 @@
let isPlaylistModalOpen = $state(false);
let playlistModalMode = $state<'create' | 'edit' | 'addTrack'>('create');
let playlistModalTrackIds = $state<number[]>([]);
let isPlaylistImportOpen = $state(false);
let isAboutModalOpen = $state(false);
let userPlaylists = $state<{ id: number; name: string; tracks_count: number }[]>([]);
@@ -801,6 +805,17 @@
sidebarRef?.refreshPlaylists();
}
function openImportPlaylist() {
openPlaylistImport();
}
function handlePlaylistImported(summary: { qobuz_playlist_id?: number | null }) {
sidebarRef?.refreshPlaylists();
if (summary.qobuz_playlist_id) {
selectPlaylist(summary.qobuz_playlist_id);
}
}
// Auth Handlers
async function handleLoginSuccess(info: UserInfo) {
setLoggedIn(info);
@@ -1053,6 +1068,7 @@
isPlaylistModalOpen = uiState.isPlaylistModalOpen;
playlistModalMode = uiState.playlistModalMode;
playlistModalTrackIds = uiState.playlistModalTrackIds;
isPlaylistImportOpen = uiState.isPlaylistImportOpen;
});
// Subscribe to auth state changes
@@ -1180,6 +1196,7 @@
onNavigate={navigateTo}
onPlaylistSelect={selectPlaylist}
onCreatePlaylist={openCreatePlaylist}
onImportPlaylist={openImportPlaylist}
onSettingsClick={() => navigateTo('settings')}
onAboutClick={() => isAboutModalOpen = true}
onLogout={handleLogout}
@@ -1478,6 +1495,13 @@
onSuccess={handlePlaylistCreated}
/>
<!-- Playlist Import Modal -->
<PlaylistImportModal
isOpen={isPlaylistImportOpen}
onClose={closePlaylistImport}
onSuccess={handlePlaylistImported}
/>
<!-- About Modal -->
<AboutModal
isOpen={isAboutModalOpen}