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:
1 parent
4b7e6b9c17
commit
22cf6d9051
17 files changed
+1932
-2
No files matched your search
@@ -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::*;
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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(""", "\"")
|
||||
.replace(""", "\"")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user