diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index fdcbf0bd4..590c51015 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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::*; diff --git a/src-tauri/src/commands/playlist_import.rs b/src-tauri/src/commands/playlist_import.rs new file mode 100644 index 000000000..c0bc4906a --- /dev/null +++ b/src-tauri/src/commands/playlist_import.rs @@ -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 { + 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, + is_public: Option, + state: State<'_, AppState>, +) -> Result { + 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()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ec5c760fc..4fe9b7028 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/playlist_import/errors.rs b/src-tauri/src/playlist_import/errors.rs new file mode 100644 index 000000000..956a1754f --- /dev/null +++ b/src-tauri/src/playlist_import/errors.rs @@ -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), +} diff --git a/src-tauri/src/playlist_import/importer.rs b/src-tauri/src/playlist_import/importer.rs new file mode 100644 index 000000000..21ce91453 --- /dev/null +++ b/src-tauri/src/playlist_import/importer.rs @@ -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 { + 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 { + 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, + }) +} diff --git a/src-tauri/src/playlist_import/match_qobuz.rs b/src-tauri/src/playlist_import/match_qobuz.rs new file mode 100644 index 000000000..908c78ad7 --- /dev/null +++ b/src-tauri/src/playlist_import/match_qobuz.rs @@ -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, 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::>() + .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 +} diff --git a/src-tauri/src/playlist_import/mod.rs b/src-tauri/src/playlist_import/mod.rs new file mode 100644 index 000000000..8ae7ae207 --- /dev/null +++ b/src-tauri/src/playlist_import/mod.rs @@ -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}; diff --git a/src-tauri/src/playlist_import/models.rs b/src-tauri/src/playlist_import/models.rs new file mode 100644 index 000000000..c86504dc0 --- /dev/null +++ b/src-tauri/src/playlist_import/models.rs @@ -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, + pub duration_ms: Option, + pub isrc: Option, + pub provider_id: Option, + pub provider_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportPlaylist { + pub provider: ImportProvider, + pub provider_id: String, + pub name: String, + pub description: Option, + pub tracks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrackMatch { + pub source: ImportTrack, + pub qobuz_track_id: Option, + pub qobuz_title: Option, + pub qobuz_artist: Option, + pub score: f32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportSummary { + pub provider: ImportProvider, + pub playlist_name: String, + pub total_tracks: u32, + pub matched_tracks: u32, + pub skipped_tracks: u32, + pub qobuz_playlist_id: Option, + pub matches: Vec, +} diff --git a/src-tauri/src/playlist_import/providers/apple.rs b/src-tauri/src/playlist_import/providers/apple.rs new file mode 100644 index 000000000..5c507f949 --- /dev/null +++ b/src-tauri/src/playlist_import/providers/apple.rs @@ -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 { + 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 { + let marker = format!("id=\"{}\"", id); + let start = html.find(&marker)?; + let script_start = html[start..].find('>')? + start + 1; + let script_end = html[script_start..].find("")? + script_start; + let raw = &html[script_start..script_end]; + Some(unescape_basic(raw)) +} + +fn find_track_items(data: &Value) -> Option> { + match data { + Value::Object(map) => { + if map.get("itemKind").and_then(|v| v.as_str()) == Some("trackLockup") { + let items = map.get("items").and_then(|v| v.as_array())?; + if !items.is_empty() { + return Some(items.iter().collect()); + } + } + + for value in map.values() { + if let Some(found) = find_track_items(value) { + return Some(found); + } + } + } + Value::Array(list) => { + for value in list { + if let Some(found) = find_track_items(value) { + return Some(found); + } + } + } + _ => {} + } + + None +} + +fn extract_meta(html: &str, property: &str) -> Option { + let needle = format!("property=\"{}\"", property); + let start = html.find(&needle)?; + let content_start = html[start..].find("content=\"")? + start + "content=\"".len(); + let content_end = html[content_start..].find('"')? + content_start; + Some(unescape_basic(&html[content_start..content_end])) +} + +fn unescape_basic(input: &str) -> String { + input + .replace(""", "\"") + .replace(""", "\"") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") +} diff --git a/src-tauri/src/playlist_import/providers/deezer.rs b/src-tauri/src/playlist_import/providers/deezer.rs new file mode 100644 index 000000000..d1788c03e --- /dev/null +++ b/src-tauri/src/playlist_import/providers/deezer.rs @@ -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 { + if !url.contains("deezer.com") { + return None; + } + + let parts: Vec<&str> = url.split('/').collect(); + for (idx, part) in parts.iter().enumerate() { + if *part == "playlist" { + let id = parts.get(idx + 1)?.split('?').next()?; + if !id.is_empty() { + return Some(id.to_string()); + } + } + } + + None +} + +pub async fn fetch_playlist(playlist_id: &str) -> Result { + let url = format!("https://api.deezer.com/playlist/{}", playlist_id); + let data: Value = 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, + }) +} diff --git a/src-tauri/src/playlist_import/providers/mod.rs b/src-tauri/src/playlist_import/providers/mod.rs new file mode 100644 index 000000000..f162c754e --- /dev/null +++ b/src-tauri/src/playlist_import/providers/mod.rs @@ -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 { + 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 { + 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, + } +} diff --git a/src-tauri/src/playlist_import/providers/spotify.rs b/src-tauri/src/playlist_import/providers/spotify.rs new file mode 100644 index 000000000..358672f5e --- /dev/null +++ b/src-tauri/src/playlist_import/providers/spotify.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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::>() + }) + .unwrap_or_default(); + + if artists.is_empty() { + "Unknown".to_string() + } else { + artists.join(", ") + } +} + +fn extract_script(html: &str, id: &str) -> Option { + let marker = format!("id=\"{}\"", id); + let start = html.find(&marker)?; + let script_start = html[start..].find('>')? + start + 1; + let script_end = html[script_start..].find("")? + script_start; + Some(html[script_start..script_end].to_string()) +} diff --git a/src-tauri/src/playlist_import/providers/tidal.rs b/src-tauri/src/playlist_import/providers/tidal.rs new file mode 100644 index 000000000..dec491c72 --- /dev/null +++ b/src-tauri/src/playlist_import/providers/tidal.rs @@ -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 { + 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 { + 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, 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, 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 { + 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 { + 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())) +} diff --git a/src/lib/components/PlaylistImportModal.svelte b/src/lib/components/PlaylistImportModal.svelte new file mode 100644 index 000000000..1ad19795d --- /dev/null +++ b/src/lib/components/PlaylistImportModal.svelte @@ -0,0 +1,522 @@ + + +{#if isOpen} + +{/if} + + diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 440bbfe10..4b86855bb 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -1,5 +1,5 @@