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()))
|
||||
}
|
||||
Reference in new issue
Block a user