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

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

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

No files matched your search

+2
View File
@@ -9,6 +9,7 @@ pub mod lastfm;
pub mod notification;
pub mod playback;
pub mod playlist;
pub mod playlist_import;
pub mod queue;
pub mod search;
pub mod share;
@@ -20,6 +21,7 @@ pub use lastfm::*;
pub use notification::*;
pub use playback::*;
pub use playlist::*;
pub use playlist_import::*;
pub use queue::*;
pub use search::*;
pub use share::*;
+35
View File
@@ -0,0 +1,35 @@
//! Playlist import commands
use tauri::State;
use crate::playlist_import::{import_public_playlist, preview_public_playlist, ImportPlaylist, ImportSummary};
use crate::AppState;
#[tauri::command]
pub async fn playlist_import_preview(url: String) -> Result<ImportPlaylist, String> {
log::info!("Command: playlist_import_preview {}", url);
preview_public_playlist(&url)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn playlist_import_execute(
url: String,
name_override: Option<String>,
is_public: Option<bool>,
state: State<'_, AppState>,
) -> Result<ImportSummary, String> {
log::info!("Command: playlist_import_execute {}", url);
let client = state.client.lock().await;
import_public_playlist(
&url,
&client,
name_override.as_deref(),
is_public.unwrap_or(false),
)
.await
.map_err(|e| e.to_string())
}
+4
View File
@@ -17,6 +17,7 @@ pub mod library;
pub mod lyrics;
pub mod media_controls;
pub mod player;
pub mod playlist_import;
pub mod queue;
pub mod reco_store;
pub mod session_store;
@@ -263,6 +264,9 @@ pub fn run() {
commands::add_tracks_to_playlist,
commands::remove_tracks_from_playlist,
commands::update_playlist,
// Playlist import commands
commands::playlist_import_preview,
commands::playlist_import_execute,
// Favorites commands
commands::get_favorites,
commands::add_favorite,
+19
View File
@@ -0,0 +1,19 @@
//! Errors for playlist import
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PlaylistImportError {
#[error("Invalid playlist URL: {0}")]
InvalidUrl(String),
#[error("Provider not supported: {0}")]
UnsupportedProvider(String),
#[error("Missing credentials: {0}")]
MissingCredentials(String),
#[error("HTTP error: {0}")]
Http(String),
#[error("Parse error: {0}")]
Parse(String),
#[error("Qobuz error: {0}")]
Qobuz(String),
}
+72
View File
@@ -0,0 +1,72 @@
//! Orchestrates playlist import
use crate::api::QobuzClient;
use crate::playlist_import::errors::PlaylistImportError;
use crate::playlist_import::match_qobuz::match_tracks;
use crate::playlist_import::models::{ImportPlaylist, ImportSummary};
use crate::playlist_import::providers::{detect_provider, fetch_playlist};
const ADD_CHUNK_SIZE: usize = 50;
pub async fn preview_public_playlist(url: &str) -> Result<ImportPlaylist, PlaylistImportError> {
let provider = detect_provider(url)?;
fetch_playlist(provider).await
}
pub async fn import_public_playlist(
url: &str,
client: &QobuzClient,
name_override: Option<&str>,
is_public: bool,
) -> Result<ImportSummary, PlaylistImportError> {
let playlist = preview_public_playlist(url).await?;
let matches = match_tracks(client, &playlist.tracks).await?;
let mut matched_track_ids = Vec::new();
let mut seen = std::collections::HashSet::new();
for entry in &matches {
if let Some(id) = entry.qobuz_track_id {
if seen.insert(id) {
matched_track_ids.push(id);
}
}
}
let matched_count = matched_track_ids.len() as u32;
let total_tracks = playlist.tracks.len() as u32;
let skipped_tracks = total_tracks.saturating_sub(matched_count);
let mut qobuz_playlist_id = None;
if !matched_track_ids.is_empty() {
let name = name_override.unwrap_or(&playlist.name);
let description = playlist
.description
.clone()
.or_else(|| Some(format!("Imported from {}", playlist.provider.as_str())));
let created = client
.create_playlist(name, description.as_deref(), is_public)
.await
.map_err(|e| PlaylistImportError::Qobuz(e.to_string()))?;
qobuz_playlist_id = Some(created.id);
for chunk in matched_track_ids.chunks(ADD_CHUNK_SIZE) {
client
.add_tracks_to_playlist(created.id, chunk)
.await
.map_err(|e| PlaylistImportError::Qobuz(e.to_string()))?;
}
}
Ok(ImportSummary {
provider: playlist.provider,
playlist_name: playlist.name,
total_tracks,
matched_tracks: matched_count,
skipped_tracks,
qobuz_playlist_id,
matches,
})
}
@@ -0,0 +1,218 @@
//! Match imported tracks to Qobuz catalog
use crate::api::models::Track;
use crate::api::QobuzClient;
use crate::playlist_import::errors::PlaylistImportError;
use crate::playlist_import::models::{ImportTrack, TrackMatch};
const SEARCH_LIMIT: u32 = 20;
const TITLE_WEIGHT: f32 = 0.6;
const ARTIST_WEIGHT: f32 = 0.3;
const ALBUM_WEIGHT: f32 = 0.1;
const MIN_SCORE: f32 = 0.65;
pub async fn match_tracks(
client: &QobuzClient,
tracks: &[ImportTrack],
) -> Result<Vec<TrackMatch>, PlaylistImportError> {
let mut results = Vec::new();
for track in tracks {
let query = format!("{} {}", track.artist, track.title);
let search = client
.search_tracks(&query, SEARCH_LIMIT, 0)
.await
.map_err(|e| PlaylistImportError::Qobuz(e.to_string()))?;
let (best, score) = select_best_match(track, &search.items);
let match_entry = match best {
Some(candidate) if score >= MIN_SCORE => TrackMatch {
source: track.clone(),
qobuz_track_id: Some(candidate.id),
qobuz_title: Some(candidate.title.clone()),
qobuz_artist: candidate.performer.as_ref().map(|a| a.name.clone()),
score,
},
_ => TrackMatch {
source: track.clone(),
qobuz_track_id: None,
qobuz_title: None,
qobuz_artist: None,
score,
},
};
results.push(match_entry);
}
Ok(results)
}
fn select_best_match<'a>(track: &ImportTrack, candidates: &'a [Track]) -> (Option<&'a Track>, f32) {
let mut best: Option<&Track> = None;
let mut best_score = 0.0f32;
let mut best_quality = 0.0f32;
for candidate in candidates {
if !candidate.streamable {
continue;
}
let score = score_candidate(track, candidate);
let quality = quality_score(candidate);
if score > best_score + 0.0001 {
best = Some(candidate);
best_score = score;
best_quality = quality;
} else if (score - best_score).abs() < 0.01 && quality > best_quality {
best = Some(candidate);
best_quality = quality;
}
}
(best, best_score)
}
fn score_candidate(track: &ImportTrack, candidate: &Track) -> f32 {
if let (Some(isrc), Some(candidate_isrc)) = (&track.isrc, &candidate.isrc) {
if isrc.eq_ignore_ascii_case(candidate_isrc) {
return 1.0;
}
}
let title_score = similarity(&track.title, &candidate.title);
let artist_score = similarity(&track.artist, candidate.performer.as_ref().map(|a| a.name.as_str()).unwrap_or(""));
let album_score = track
.album
.as_ref()
.map(|album| {
candidate
.album
.as_ref()
.map(|a| similarity(album, &a.title))
.unwrap_or(0.0)
})
.unwrap_or(0.0);
let mut score = title_score * TITLE_WEIGHT + artist_score * ARTIST_WEIGHT + album_score * ALBUM_WEIGHT;
if let (Some(import_duration), Some(candidate_duration)) = (track.duration_ms, Some((candidate.duration as u64).saturating_mul(1000))) {
let diff = if import_duration > candidate_duration {
import_duration - candidate_duration
} else {
candidate_duration - import_duration
};
if diff <= 3000 {
score += 0.05;
} else if diff <= 5000 {
score += 0.02;
}
}
score
}
fn similarity(a: &str, b: &str) -> f32 {
let na = normalize(a);
let nb = normalize(b);
if na.is_empty() || nb.is_empty() {
return 0.0;
}
if na == nb {
return 1.0;
}
if na.contains(&nb) || nb.contains(&na) {
return 0.85;
}
token_overlap(&na, &nb)
}
fn normalize(input: &str) -> String {
let stripped = remove_bracketed(input);
let mut cleaned = String::new();
for ch in stripped.chars() {
if ch.is_ascii_alphanumeric() || ch.is_whitespace() {
cleaned.push(ch.to_ascii_lowercase());
} else {
cleaned.push(' ');
}
}
let stop_words = [
"remaster",
"remastered",
"deluxe",
"edition",
"live",
"feat",
"featuring",
"version",
"mix",
"mono",
"stereo",
"edit",
];
cleaned
.split_whitespace()
.filter(|token| !stop_words.contains(token))
.collect::<Vec<_>>()
.join(" ")
}
fn remove_bracketed(input: &str) -> String {
let mut out = String::new();
let mut depth = 0u32;
for ch in input.chars() {
match ch {
'(' | '[' => {
depth += 1;
}
')' | ']' => {
if depth > 0 {
depth -= 1;
}
}
_ => {
if depth == 0 {
out.push(ch);
}
}
}
}
out
}
fn token_overlap(a: &str, b: &str) -> f32 {
let a_tokens: Vec<&str> = a.split_whitespace().collect();
let b_tokens: Vec<&str> = b.split_whitespace().collect();
if a_tokens.is_empty() || b_tokens.is_empty() {
return 0.0;
}
let mut matches = 0u32;
for token in &a_tokens {
if b_tokens.contains(token) {
matches += 1;
}
}
matches as f32 / a_tokens.len().max(b_tokens.len()) as f32
}
fn quality_score(track: &Track) -> f32 {
let bit_depth = track.maximum_bit_depth.unwrap_or(0) as f32;
let sample_rate = track.maximum_sampling_rate.unwrap_or(0.0) as f32;
bit_depth * 100000.0 + sample_rate
}
+11
View File
@@ -0,0 +1,11 @@
//! Public playlist import
pub mod errors;
pub mod importer;
pub mod match_qobuz;
pub mod models;
pub mod providers;
pub use errors::PlaylistImportError;
pub use importer::{import_public_playlist, preview_public_playlist};
pub use models::{ImportPlaylist, ImportProvider, ImportSummary, ImportTrack, TrackMatch};
+62
View File
@@ -0,0 +1,62 @@
//! Data models for playlist import
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ImportProvider {
Spotify,
AppleMusic,
Tidal,
Deezer,
}
impl ImportProvider {
pub fn as_str(&self) -> &'static str {
match self {
ImportProvider::Spotify => "spotify",
ImportProvider::AppleMusic => "apple_music",
ImportProvider::Tidal => "tidal",
ImportProvider::Deezer => "deezer",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportTrack {
pub title: String,
pub artist: String,
pub album: Option<String>,
pub duration_ms: Option<u64>,
pub isrc: Option<String>,
pub provider_id: Option<String>,
pub provider_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportPlaylist {
pub provider: ImportProvider,
pub provider_id: String,
pub name: String,
pub description: Option<String>,
pub tracks: Vec<ImportTrack>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrackMatch {
pub source: ImportTrack,
pub qobuz_track_id: Option<u64>,
pub qobuz_title: Option<String>,
pub qobuz_artist: Option<String>,
pub score: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportSummary {
pub provider: ImportProvider,
pub playlist_name: String,
pub total_tracks: u32,
pub matched_tracks: u32,
pub skipped_tracks: u32,
pub qobuz_playlist_id: Option<u64>,
pub matches: Vec<TrackMatch>,
}
@@ -0,0 +1,156 @@
//! Apple Music playlist import
use serde_json::Value;
use crate::playlist_import::errors::PlaylistImportError;
use crate::playlist_import::models::{ImportPlaylist, ImportProvider, ImportTrack};
pub fn parse_playlist_id(url: &str) -> Option<(String, String)> {
if !url.contains("music.apple.com/") {
return None;
}
let parts: Vec<&str> = url.split('/').collect();
if parts.len() < 6 {
return None;
}
let storefront = parts.get(3)?.to_string();
let playlist_id = parts.last()?.split('?').next()?.to_string();
if playlist_id.starts_with("pl.") || playlist_id.starts_with("pl.u-") {
Some((storefront, playlist_id))
} else {
None
}
}
pub async fn fetch_playlist(storefront: &str, playlist_id: &str) -> Result<ImportPlaylist, PlaylistImportError> {
let url = format!("https://music.apple.com/{}/playlist/{}", storefront, playlist_id);
let html = reqwest::get(&url)
.await
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
.text()
.await
.map_err(|e| PlaylistImportError::Http(e.to_string()))?;
let name = extract_meta(&html, "og:title").unwrap_or_else(|| "Apple Music Playlist".to_string());
let description = extract_meta(&html, "og:description").filter(|v| !v.is_empty());
let json_text = extract_script(&html, "serialized-server-data")
.ok_or_else(|| PlaylistImportError::Parse("Apple Music serialized-server-data not found".to_string()))?;
let data: Value = serde_json::from_str(&json_text)
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
let items = find_track_items(&data)
.ok_or_else(|| PlaylistImportError::Parse("Apple Music track list not found".to_string()))?;
let mut tracks = Vec::new();
for item in items {
let title = item
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string();
let artist = item
.get("artistName")
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string();
let duration_ms = item
.get("duration")
.and_then(|v| v.as_u64());
let provider_id = item
.get("contentDescriptor")
.and_then(|v| v.get("identifiers"))
.and_then(|v| v.get("storeAdamID"))
.and_then(|v| v.as_str())
.map(|v| v.to_string());
let provider_url = item
.get("contentDescriptor")
.and_then(|v| v.get("url"))
.and_then(|v| v.as_str())
.map(|v| v.to_string());
let album = item
.get("tertiaryLinks")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.get("title"))
.and_then(|v| v.as_str())
.map(|v| v.to_string());
tracks.push(ImportTrack {
title,
artist,
album,
duration_ms,
isrc: None,
provider_id,
provider_url,
});
}
Ok(ImportPlaylist {
provider: ImportProvider::AppleMusic,
provider_id: playlist_id.to_string(),
name,
description,
tracks,
})
}
fn extract_script(html: &str, id: &str) -> Option<String> {
let marker = format!("id=\"{}\"", id);
let start = html.find(&marker)?;
let script_start = html[start..].find('>')? + start + 1;
let script_end = html[script_start..].find("</script>")? + script_start;
let raw = &html[script_start..script_end];
Some(unescape_basic(raw))
}
fn find_track_items(data: &Value) -> Option<Vec<&Value>> {
match data {
Value::Object(map) => {
if map.get("itemKind").and_then(|v| v.as_str()) == Some("trackLockup") {
let items = map.get("items").and_then(|v| v.as_array())?;
if !items.is_empty() {
return Some(items.iter().collect());
}
}
for value in map.values() {
if let Some(found) = find_track_items(value) {
return Some(found);
}
}
}
Value::Array(list) => {
for value in list {
if let Some(found) = find_track_items(value) {
return Some(found);
}
}
}
_ => {}
}
None
}
fn extract_meta(html: &str, property: &str) -> Option<String> {
let needle = format!("property=\"{}\"", property);
let start = html.find(&needle)?;
let content_start = html[start..].find("content=\"")? + start + "content=\"".len();
let content_end = html[content_start..].find('"')? + content_start;
Some(unescape_basic(&html[content_start..content_end]))
}
fn unescape_basic(input: &str) -> String {
input
.replace("&quot;", "\"")
.replace("&#34;", "\"")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
}
@@ -0,0 +1,105 @@
//! Deezer playlist import
use serde_json::Value;
use crate::playlist_import::errors::PlaylistImportError;
use crate::playlist_import::models::{ImportPlaylist, ImportProvider, ImportTrack};
pub fn parse_playlist_id(url: &str) -> Option<String> {
if !url.contains("deezer.com") {
return None;
}
let parts: Vec<&str> = url.split('/').collect();
for (idx, part) in parts.iter().enumerate() {
if *part == "playlist" {
let id = parts.get(idx + 1)?.split('?').next()?;
if !id.is_empty() {
return Some(id.to_string());
}
}
}
None
}
pub async fn fetch_playlist(playlist_id: &str) -> Result<ImportPlaylist, PlaylistImportError> {
let url = format!("https://api.deezer.com/playlist/{}", playlist_id);
let data: Value = reqwest::get(&url)
.await
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
.json()
.await
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
let name = data
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("Deezer Playlist")
.to_string();
let description = data
.get("description")
.and_then(|v| v.as_str())
.map(|v| v.to_string())
.filter(|v| !v.is_empty());
let mut tracks = Vec::new();
let items = data
.get("tracks")
.and_then(|v| v.get("data"))
.and_then(|v| v.as_array())
.ok_or_else(|| PlaylistImportError::Parse("Deezer tracks missing".to_string()))?;
for item in items {
let title = item
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string();
let artist = item
.get("artist")
.and_then(|v| v.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string();
let album = item
.get("album")
.and_then(|v| v.get("title"))
.and_then(|v| v.as_str())
.map(|v| v.to_string());
let duration_ms = item
.get("duration")
.and_then(|v| v.as_u64())
.map(|v| v * 1000);
let isrc = item
.get("isrc")
.and_then(|v| v.as_str())
.map(|v| v.to_string());
let provider_id = item
.get("id")
.and_then(|v| v.as_u64())
.map(|v| v.to_string());
let provider_url = item
.get("link")
.and_then(|v| v.as_str())
.map(|v| v.to_string());
tracks.push(ImportTrack {
title,
artist,
album,
duration_ms,
isrc,
provider_id,
provider_url,
});
}
Ok(ImportPlaylist {
provider: ImportProvider::Deezer,
provider_id: playlist_id.to_string(),
name,
description,
tracks,
})
}
@@ -0,0 +1,45 @@
//! Provider implementations
pub mod apple;
pub mod deezer;
pub mod spotify;
pub mod tidal;
use crate::playlist_import::errors::PlaylistImportError;
use crate::playlist_import::models::ImportPlaylist;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProviderKind {
Spotify { playlist_id: String },
AppleMusic { storefront: String, playlist_id: String },
Tidal { playlist_id: String },
Deezer { playlist_id: String },
}
pub fn detect_provider(url: &str) -> Result<ProviderKind, PlaylistImportError> {
if let Some(id) = spotify::parse_playlist_id(url) {
return Ok(ProviderKind::Spotify { playlist_id: id });
}
if let Some((storefront, id)) = apple::parse_playlist_id(url) {
return Ok(ProviderKind::AppleMusic { storefront, playlist_id: id });
}
if let Some(id) = tidal::parse_playlist_id(url) {
return Ok(ProviderKind::Tidal { playlist_id: id });
}
if let Some(id) = deezer::parse_playlist_id(url) {
return Ok(ProviderKind::Deezer { playlist_id: id });
}
Err(PlaylistImportError::UnsupportedProvider(url.to_string()))
}
pub async fn fetch_playlist(kind: ProviderKind) -> Result<ImportPlaylist, PlaylistImportError> {
match kind {
ProviderKind::Spotify { playlist_id } => spotify::fetch_playlist(&playlist_id).await,
ProviderKind::AppleMusic { storefront, playlist_id } => {
apple::fetch_playlist(&storefront, &playlist_id).await
}
ProviderKind::Tidal { playlist_id } => tidal::fetch_playlist(&playlist_id).await,
ProviderKind::Deezer { playlist_id } => deezer::fetch_playlist(&playlist_id).await,
}
}
@@ -0,0 +1,301 @@
//! Spotify playlist import
use std::env;
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use serde_json::Value;
use crate::playlist_import::errors::PlaylistImportError;
use crate::playlist_import::models::{ImportPlaylist, ImportProvider, ImportTrack};
const SPOTIFY_TOKEN_URL: &str = "https://accounts.spotify.com/api/token";
const SPOTIFY_API_BASE: &str = "https://api.spotify.com/v1";
pub fn parse_playlist_id(url: &str) -> Option<String> {
if let Some(rest) = url.strip_prefix("spotify:playlist:") {
if !rest.is_empty() {
return Some(rest.to_string());
}
}
let patterns = [
"open.spotify.com/playlist/",
"open.spotify.com/embed/playlist/",
];
for pattern in patterns {
if let Some(idx) = url.find(pattern) {
let mut part = &url[idx + pattern.len()..];
if let Some(end) = part.find('?') {
part = &part[..end];
}
if !part.is_empty() {
return Some(part.to_string());
}
}
}
None
}
pub async fn fetch_playlist(playlist_id: &str) -> Result<ImportPlaylist, PlaylistImportError> {
if let Ok(token) = get_app_token().await {
if let Ok(playlist) = fetch_playlist_with_token(playlist_id, &token).await {
return Ok(playlist);
}
}
fetch_playlist_from_embed(playlist_id).await
}
async fn fetch_playlist_with_token(
playlist_id: &str,
access_token: &str,
) -> Result<ImportPlaylist, PlaylistImportError> {
let client = reqwest::Client::new();
let meta_url = format!("{}/playlists/{}", SPOTIFY_API_BASE, playlist_id);
let meta: Value = client
.get(&meta_url)
.header("Authorization", format!("Bearer {}", access_token))
.query(&[("fields", "name,description,tracks.total")])
.send()
.await
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
.json()
.await
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
let name = meta
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Spotify Playlist")
.to_string();
let description = meta
.get("description")
.and_then(|v| v.as_str())
.map(|v| v.to_string())
.filter(|v| !v.is_empty());
let mut tracks = Vec::new();
let mut offset = 0u32;
let limit = 100u32;
loop {
let tracks_url = format!("{}/playlists/{}/tracks", SPOTIFY_API_BASE, playlist_id);
let response: Value = client
.get(&tracks_url)
.header("Authorization", format!("Bearer {}", access_token))
.query(&[
("limit", limit.to_string()),
("offset", offset.to_string()),
("fields", "items(track(name,artists(name),album(name),duration_ms,external_ids,id,external_urls)),next".to_string()),
])
.send()
.await
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
.json()
.await
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
let items = response
.get("items")
.and_then(|v| v.as_array())
.ok_or_else(|| PlaylistImportError::Parse("Spotify tracks missing items".to_string()))?;
for item in items {
let track = match item.get("track") {
Some(v) if !v.is_null() => v,
_ => continue,
};
let title = track
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string();
let artist = join_artists(track.get("artists"));
let album = track
.get("album")
.and_then(|v| v.get("name"))
.and_then(|v| v.as_str())
.map(|v| v.to_string());
let duration_ms = track
.get("duration_ms")
.and_then(|v| v.as_u64());
let isrc = track
.get("external_ids")
.and_then(|v| v.get("isrc"))
.and_then(|v| v.as_str())
.map(|v| v.to_string());
let provider_id = track
.get("id")
.and_then(|v| v.as_str())
.map(|v| v.to_string());
let provider_url = track
.get("external_urls")
.and_then(|v| v.get("spotify"))
.and_then(|v| v.as_str())
.map(|v| v.to_string());
tracks.push(ImportTrack {
title,
artist,
album,
duration_ms,
isrc,
provider_id,
provider_url,
});
}
let has_next = response
.get("next")
.and_then(|v| v.as_str())
.map(|v| !v.is_empty())
.unwrap_or(false);
if !has_next {
break;
}
offset += limit;
}
Ok(ImportPlaylist {
provider: ImportProvider::Spotify,
provider_id: playlist_id.to_string(),
name,
description,
tracks,
})
}
async fn fetch_playlist_from_embed(playlist_id: &str) -> Result<ImportPlaylist, PlaylistImportError> {
let url = format!("https://open.spotify.com/embed/playlist/{}", playlist_id);
let html = reqwest::get(&url)
.await
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
.text()
.await
.map_err(|e| PlaylistImportError::Http(e.to_string()))?;
let json_text = extract_script(&html, "__NEXT_DATA__")
.ok_or_else(|| PlaylistImportError::Parse("Spotify embed missing __NEXT_DATA__".to_string()))?;
let data: Value = serde_json::from_str(&json_text)
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
let entity = data
.get("props")
.and_then(|v| v.get("pageProps"))
.and_then(|v| v.get("state"))
.and_then(|v| v.get("data"))
.and_then(|v| v.get("entity"))
.ok_or_else(|| PlaylistImportError::Parse("Spotify embed missing entity".to_string()))?;
let name = entity
.get("title")
.or_else(|| entity.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("Spotify Playlist")
.to_string();
let mut tracks = Vec::new();
let track_list = entity
.get("trackList")
.and_then(|v| v.as_array())
.ok_or_else(|| PlaylistImportError::Parse("Spotify embed missing trackList".to_string()))?;
for track in track_list {
let title = track
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string();
let artist = track
.get("subtitle")
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string();
let duration_ms = track
.get("duration")
.and_then(|v| v.as_u64());
let uri = track
.get("uri")
.and_then(|v| v.as_str())
.unwrap_or("");
let provider_id = uri.split(':').last().filter(|v| !v.is_empty()).map(|v| v.to_string());
let provider_url = provider_id.as_ref().map(|id| format!("https://open.spotify.com/track/{}", id));
tracks.push(ImportTrack {
title,
artist,
album: None,
duration_ms,
isrc: None,
provider_id,
provider_url,
});
}
Ok(ImportPlaylist {
provider: ImportProvider::Spotify,
provider_id: playlist_id.to_string(),
name,
description: None,
tracks,
})
}
async fn get_app_token() -> Result<String, PlaylistImportError> {
let client_id = env::var("SPOTIFY_API_CLIENT_ID")
.map_err(|_| PlaylistImportError::MissingCredentials("SPOTIFY_API_CLIENT_ID".to_string()))?;
let client_secret = env::var("SPOTIFY_API_CLIENT_SECRET")
.map_err(|_| PlaylistImportError::MissingCredentials("SPOTIFY_API_CLIENT_SECRET".to_string()))?;
let auth = STANDARD.encode(format!("{}:{}", client_id, client_secret));
let response: Value = reqwest::Client::new()
.post(SPOTIFY_TOKEN_URL)
.header("Authorization", format!("Basic {}", auth))
.header("Content-Type", "application/x-www-form-urlencoded")
.body("grant_type=client_credentials")
.send()
.await
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
.json()
.await
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
response
.get("access_token")
.and_then(|v| v.as_str())
.map(|v| v.to_string())
.ok_or_else(|| PlaylistImportError::Parse("Spotify token missing access_token".to_string()))
}
fn join_artists(value: Option<&Value>) -> String {
let artists = value
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|a| a.get("name").and_then(|v| v.as_str()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
if artists.is_empty() {
"Unknown".to_string()
} else {
artists.join(", ")
}
}
fn extract_script(html: &str, id: &str) -> Option<String> {
let marker = format!("id=\"{}\"", id);
let start = html.find(&marker)?;
let script_start = html[start..].find('>')? + start + 1;
let script_end = html[script_start..].find("</script>")? + script_start;
Some(html[script_start..script_end].to_string())
}
@@ -0,0 +1,326 @@
//! Tidal playlist import (OpenAPI v2)
use std::env;
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use serde_json::Value;
use crate::playlist_import::errors::PlaylistImportError;
use crate::playlist_import::models::{ImportPlaylist, ImportProvider, ImportTrack};
const TIDAL_TOKEN_URL: &str = "https://auth.tidal.com/v1/oauth2/token";
const TIDAL_API_BASE: &str = "https://openapi.tidal.com/v2";
pub fn parse_playlist_id(url: &str) -> Option<String> {
if !url.contains("tidal.com") {
return None;
}
let patterns = ["/browse/playlist/", "/playlist/"];
for pattern in patterns {
if let Some(idx) = url.find(pattern) {
let mut part = &url[idx + pattern.len()..];
if let Some(end) = part.find('?') {
part = &part[..end];
}
if !part.is_empty() {
return Some(part.to_string());
}
}
}
None
}
pub async fn fetch_playlist(playlist_id: &str) -> Result<ImportPlaylist, PlaylistImportError> {
let token = get_app_token().await?;
let country_code = env::var("TIDAL_COUNTRY_CODE").unwrap_or_else(|_| "US".to_string());
let client = reqwest::Client::new();
let meta_url = format!("{}/playlists/{}", TIDAL_API_BASE, playlist_id);
let meta: Value = client
.get(&meta_url)
.header("Authorization", format!("Bearer {}", token))
.query(&[("countryCode", &country_code)])
.send()
.await
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
.json()
.await
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
let name = meta
.get("data")
.and_then(|v| v.get("attributes"))
.and_then(|v| v.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("Tidal Playlist")
.to_string();
let description = meta
.get("data")
.and_then(|v| v.get("attributes"))
.and_then(|v| v.get("description"))
.and_then(|v| v.as_str())
.map(|v| v.to_string())
.filter(|v| !v.is_empty());
let track_ids = fetch_track_ids(&client, &token, playlist_id, &country_code).await?;
let tracks = fetch_tracks_by_ids(&client, &token, &track_ids, &country_code).await?;
Ok(ImportPlaylist {
provider: ImportProvider::Tidal,
provider_id: playlist_id.to_string(),
name,
description,
tracks,
})
}
async fn fetch_track_ids(
client: &reqwest::Client,
token: &str,
playlist_id: &str,
country_code: &str,
) -> Result<Vec<String>, PlaylistImportError> {
let mut ids = Vec::new();
let mut next_path = format!("/playlists/{}/relationships/items?limit=100", playlist_id);
loop {
let url = format!("{}{}", TIDAL_API_BASE, next_path);
let mut request = client
.get(&url)
.header("Authorization", format!("Bearer {}", token));
if !next_path.contains("countryCode=") {
request = request.query(&[("countryCode", country_code)]);
}
let response: Value = request
.send()
.await
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
.json()
.await
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
if let Some(data) = response.get("data").and_then(|v| v.as_array()) {
for item in data {
if let Some(id) = item.get("id").and_then(|v| v.as_str()) {
ids.push(id.to_string());
}
}
}
let next = response
.get("links")
.and_then(|v| v.get("next"))
.and_then(|v| v.as_str())
.map(|v| v.to_string());
match next {
Some(path) if !path.is_empty() => {
next_path = path;
}
_ => break,
}
}
Ok(ids)
}
async fn fetch_tracks_by_ids(
client: &reqwest::Client,
token: &str,
track_ids: &[String],
country_code: &str,
) -> Result<Vec<ImportTrack>, PlaylistImportError> {
let mut tracks = Vec::new();
let mut chunk_start = 0usize;
let chunk_size = 50usize;
while chunk_start < track_ids.len() {
let end = (chunk_start + chunk_size).min(track_ids.len());
let chunk = &track_ids[chunk_start..end];
let url = format!("{}/tracks", TIDAL_API_BASE);
let response: Value = client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.query(&[
("filter[id]", chunk.join(",")),
("include", "artists,albums".to_string()),
("countryCode", country_code.to_string()),
])
.send()
.await
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
.json()
.await
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
let included = response
.get("included")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut artist_map = std::collections::HashMap::new();
let mut album_map = std::collections::HashMap::new();
for item in included {
if let Some(item_type) = item.get("type").and_then(|v| v.as_str()) {
let id = item.get("id").and_then(|v| v.as_str()).unwrap_or("");
if id.is_empty() {
continue;
}
match item_type {
"artists" => {
if let Some(name) = item
.get("attributes")
.and_then(|v| v.get("name"))
.and_then(|v| v.as_str())
{
artist_map.insert(id.to_string(), name.to_string());
}
}
"albums" => {
if let Some(name) = item
.get("attributes")
.and_then(|v| v.get("title"))
.and_then(|v| v.as_str())
{
album_map.insert(id.to_string(), name.to_string());
}
}
_ => {}
}
}
}
if let Some(data) = response.get("data").and_then(|v| v.as_array()) {
for item in data {
let title = item
.get("attributes")
.and_then(|v| v.get("title"))
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string();
let isrc = item
.get("attributes")
.and_then(|v| v.get("isrc"))
.and_then(|v| v.as_str())
.map(|v| v.to_string());
let duration_ms = item
.get("attributes")
.and_then(|v| v.get("duration"))
.and_then(|v| v.as_str())
.and_then(|v| parse_duration_ms(v));
let artist = item
.get("relationships")
.and_then(|v| v.get("artists"))
.and_then(|v| v.get("data"))
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.get("id"))
.and_then(|v| v.as_str())
.and_then(|id| artist_map.get(id))
.cloned()
.unwrap_or_else(|| "Unknown".to_string());
let album = item
.get("relationships")
.and_then(|v| v.get("albums"))
.and_then(|v| v.get("data"))
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.get("id"))
.and_then(|v| v.as_str())
.and_then(|id| album_map.get(id))
.cloned();
let provider_id = item
.get("id")
.and_then(|v| v.as_str())
.map(|v| v.to_string());
tracks.push(ImportTrack {
title,
artist,
album,
duration_ms,
isrc,
provider_id,
provider_url: None,
});
}
}
chunk_start = end;
}
Ok(tracks)
}
fn parse_duration_ms(value: &str) -> Option<u64> {
if !value.starts_with('P') {
return None;
}
let mut seconds = 0u64;
let mut parsed_any = false;
let mut num = String::new();
for ch in value.chars() {
if ch.is_ascii_digit() {
num.push(ch);
continue;
}
if num.is_empty() {
continue;
}
let value_num: u64 = num.parse().ok()?;
parsed_any = true;
match ch {
'H' => seconds += value_num * 3600,
'M' => seconds += value_num * 60,
'S' => seconds += value_num,
_ => {}
}
num.clear();
}
if !parsed_any {
None
} else {
Some(seconds * 1000)
}
}
async fn get_app_token() -> Result<String, PlaylistImportError> {
let client_id = env::var("TIDAL_API_CLIENT_ID")
.map_err(|_| PlaylistImportError::MissingCredentials("TIDAL_API_CLIENT_ID".to_string()))?;
let client_secret = env::var("TIDAL_API_CLIENT_SECRET")
.map_err(|_| PlaylistImportError::MissingCredentials("TIDAL_API_CLIENT_SECRET".to_string()))?;
let auth = STANDARD.encode(format!("{}:{}", client_id, client_secret));
let response: Value = reqwest::Client::new()
.post(TIDAL_TOKEN_URL)
.header("Authorization", format!("Basic {}", auth))
.header("Content-Type", "application/x-www-form-urlencoded")
.body("grant_type=client_credentials")
.send()
.await
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
.json()
.await
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
response
.get("access_token")
.and_then(|v| v.as_str())
.map(|v| v.to_string())
.ok_or_else(|| PlaylistImportError::Parse("Tidal token missing access_token".to_string()))
}