feat(slint): add-to-playlist duplicate modal + playlist importer port
Add-to-playlist (Slices 0-3): - qbz-core: check_playlist_duplicates + pure compute_playlist_duplicates (reuses qbz_models::PlaylistDuplicateResult) + unit tests. - PlaylistDuplicateConfirmModal (Add all / Add new only / Cancel); the duplicate check runs on the Qobuz->Qobuz picker branch only (local/Plex add directly, mirroring Tauri); the track-id Vec lives Rust-side in a stash, never in Slint. - Inline create-and-add in the picker (online create_playlist, offline -> local playlist per D8) + client-side filter (Slint 1.16 has no string contains, routed through a Rust callback) + success toasts. Playlist importer (1:1 Tauri port): - New headless crate qbz-playlist-import (Spotify/Apple/Tidal/Deezer scrapers, Qobuz matcher, importer, progress sink, shared http client; copy-not-move from src-tauri, zero tauri deps; 40 unit tests green). - PlaylistImportModal (two-step fetch-preview -> import with live progress bar / log / summary) + controller + sidebar PLAYLISTS menu entry point. Reco logging staged behind TODO(reco-v1) markers (golden rule: playlist recommendations ship in Slint v1 once the reco engine is extracted to a shared crate). cc
This commit is contained in:
33 files changed
+5664
-129
No files matched your search
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "qbz-playlist-import"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Public-playlist import for QBZ — provider scrapers, Qobuz matcher, playlist creation (headless, frontend-agnostic)"
|
||||
authors = ["blitzkriegfc"]
|
||||
|
||||
[dependencies]
|
||||
# Shared QBZ crates (the real models + client, not src-tauri shims)
|
||||
qbz-models = { path = "../qbz-models" }
|
||||
qbz-qobuz = { path = "../qbz-qobuz" }
|
||||
|
||||
# HTTP (one shared client for all providers — see src/http.rs)
|
||||
reqwest = { workspace = true }
|
||||
|
||||
# Serialization
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# Error handling
|
||||
thiserror = { workspace = true }
|
||||
|
||||
# Logging
|
||||
log = { workspace = true }
|
||||
|
||||
# Async runtime + concurrency
|
||||
tokio = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Errors for playlist import
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
// The Tauri original also had a `MissingCredentials` variant — dead code
|
||||
// (zero call sites since the providers moved to the credential proxy);
|
||||
// dropped in the extraction along with `ProviderCredentials`.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PlaylistImportError {
|
||||
#[error("Invalid playlist URL: {0}")]
|
||||
InvalidUrl(String),
|
||||
#[error("Provider not supported: {0}")]
|
||||
UnsupportedProvider(String),
|
||||
#[error("HTTP error: {0}")]
|
||||
Http(String),
|
||||
#[error("Parse error: {0}")]
|
||||
Parse(String),
|
||||
#[error("Qobuz error: {0}")]
|
||||
Qobuz(String),
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Shared HTTP client for all providers.
|
||||
//!
|
||||
//! The Tauri original built a new connection pool per request (`reqwest::get`
|
||||
//! in four call sites, `Client::new()` per Tidal fetch, plus a builder client
|
||||
//! per token fetch) — each one its own fd pool, which exhausted file
|
||||
//! descriptors (EMFILE) on large imports. One `OnceLock` client fixes that;
|
||||
//! async flavor of the existing blocking precedent in
|
||||
//! `qbz-media-controls/src/notify.rs`.
|
||||
//!
|
||||
//! UA policy (behavior-faithful): NO default User-Agent on the client —
|
||||
//! the scrapers go out exactly as before; only the Tidal proxy token request
|
||||
//! sets [`USER_AGENT`] per-request, as the original did.
|
||||
|
||||
/// User-Agent sent to the QBZ credential proxy (Tidal token fetch only).
|
||||
pub(crate) const USER_AGENT: &str = "QBZ/1.0.0";
|
||||
|
||||
const CONNECT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
pub(crate) fn http() -> &'static reqwest::Client {
|
||||
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
|
||||
CLIENT.get_or_init(|| {
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
|
||||
.build()
|
||||
.expect("static reqwest client")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Orchestrates playlist import
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use qbz_qobuz::QobuzClient;
|
||||
|
||||
use crate::errors::PlaylistImportError;
|
||||
use crate::match_qobuz::match_tracks;
|
||||
use crate::models::{ImportPlaylist, ImportProgress, ImportSummary};
|
||||
use crate::providers::{detect_provider, fetch_playlist};
|
||||
use crate::sink::{ImportEvent, ImportPhase, ImportProgressSink};
|
||||
|
||||
const ADD_CHUNK_SIZE: usize = 50;
|
||||
const QOBUZ_PLAYLIST_TRACK_LIMIT: usize = 2000;
|
||||
|
||||
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,
|
||||
progress: Arc<dyn ImportProgressSink>,
|
||||
) -> Result<ImportSummary, PlaylistImportError> {
|
||||
let playlist = preview_public_playlist(url).await?;
|
||||
|
||||
// Phase: matching
|
||||
progress.emit(ImportEvent::Phase(ImportPhase::Matching));
|
||||
let matches = match_tracks(client, &playlist.tracks, Arc::clone(&progress)).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_ids = Vec::new();
|
||||
|
||||
if !matched_track_ids.is_empty() {
|
||||
let base_name = name_override.unwrap_or(&playlist.name);
|
||||
let description = playlist
|
||||
.description
|
||||
.clone()
|
||||
.or_else(|| Some(format!("Imported from {}", playlist.provider.as_str())));
|
||||
|
||||
// Split into parts if more than QOBUZ_PLAYLIST_TRACK_LIMIT tracks
|
||||
let parts: Vec<&[u64]> = matched_track_ids
|
||||
.chunks(QOBUZ_PLAYLIST_TRACK_LIMIT)
|
||||
.collect();
|
||||
let total_parts = parts.len();
|
||||
|
||||
for (part_idx, part_tracks) in parts.iter().enumerate() {
|
||||
// Phase: creating (per part)
|
||||
progress.emit(ImportEvent::Phase(ImportPhase::Creating));
|
||||
|
||||
let playlist_name = if total_parts == 1 {
|
||||
base_name.to_string()
|
||||
} else {
|
||||
format!("{} (Part {})", base_name, part_idx + 1)
|
||||
};
|
||||
|
||||
let part_desc = if total_parts == 1 {
|
||||
description.clone()
|
||||
} else {
|
||||
Some(format!(
|
||||
"Part {} of {} — {}",
|
||||
part_idx + 1,
|
||||
total_parts,
|
||||
description.as_deref().unwrap_or("")
|
||||
))
|
||||
};
|
||||
|
||||
let created = client
|
||||
.create_playlist(&playlist_name, part_desc.as_deref(), is_public)
|
||||
.await
|
||||
.map_err(|e| PlaylistImportError::Qobuz(e.to_string()))?;
|
||||
|
||||
qobuz_playlist_ids.push(created.id);
|
||||
|
||||
// Phase: adding
|
||||
progress.emit(ImportEvent::Phase(ImportPhase::Adding));
|
||||
|
||||
let chunks: Vec<&[u64]> = part_tracks.chunks(ADD_CHUNK_SIZE).collect();
|
||||
let total_chunks = chunks.len() as u32;
|
||||
|
||||
for (i, chunk) in chunks.iter().enumerate() {
|
||||
client
|
||||
.add_tracks_to_playlist(created.id, chunk)
|
||||
.await
|
||||
.map_err(|e| PlaylistImportError::Qobuz(e.to_string()))?;
|
||||
|
||||
progress.emit(ImportEvent::Progress(ImportProgress {
|
||||
phase: "adding".to_string(),
|
||||
current: (i as u32) + 1,
|
||||
total: total_chunks,
|
||||
matched_so_far: matched_count,
|
||||
current_track: if total_parts > 1 {
|
||||
Some(format!("Part {}/{}", part_idx + 1, total_parts))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let parts_created = qobuz_playlist_ids.len() as u32;
|
||||
|
||||
Ok(ImportSummary {
|
||||
provider: playlist.provider,
|
||||
// Deliberate fix vs the Tauri original (owner decision): the summary
|
||||
// reports the name the playlist was actually created under — the
|
||||
// rename when one was given — not the original source name.
|
||||
playlist_name: match name_override {
|
||||
Some(name) => name.to_string(),
|
||||
None => playlist.name,
|
||||
},
|
||||
total_tracks,
|
||||
matched_tracks: matched_count,
|
||||
skipped_tracks,
|
||||
qobuz_playlist_ids,
|
||||
parts_created,
|
||||
matches,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Public-playlist import for QBZ — provider scrapers, Qobuz matcher,
|
||||
//! playlist creation (headless, frontend-agnostic).
|
||||
//!
|
||||
//! Extracted verbatim from `src-tauri/src/playlist_import/*` and re-typed
|
||||
//! against the shared crates (`qbz-models`, `qbz-qobuz`), so it runs headless
|
||||
//! with no Tauri state and no `#[tauri::command]` wrappers (ADR-005: no
|
||||
//! legacy wrappers; ADR-006: frontend-agnostic core). Progress reaches the
|
||||
//! frontend through the [`sink::ImportProgressSink`] trait instead of
|
||||
//! `AppHandle::emit`.
|
||||
//!
|
||||
//! Known provider limitations (behavior-faithful copies of the Tauri code;
|
||||
//! follow-up TODOs, not bugs introduced by the extraction):
|
||||
//! - Spotify: embed scraping only (API access gone since 2026-03-06) — caps
|
||||
//! at ~50 tracks and provides no ISRC or album data.
|
||||
//! TODO: pagination/ISRC if a richer public source appears.
|
||||
//! - Deezer: single public API call, no pagination — truncates around 400
|
||||
//! tracks. TODO: paginate `tracks.data`.
|
||||
//! - Apple Music: scrapes `serialized-server-data` from the playlist page —
|
||||
//! the most fragile parser of the four.
|
||||
//! - Tidal: fetches a fresh proxy token per playlist fetch (no caching/expiry
|
||||
//! handling). TODO: cache the token until expiry.
|
||||
//! - Scrapers send no browser User-Agent (reqwest default) — TODO if any
|
||||
//! provider starts gating on UA.
|
||||
|
||||
pub mod errors;
|
||||
pub mod importer;
|
||||
pub mod match_qobuz;
|
||||
pub mod models;
|
||||
pub mod providers;
|
||||
pub mod sink;
|
||||
|
||||
mod http;
|
||||
|
||||
pub use errors::PlaylistImportError;
|
||||
pub use importer::{import_public_playlist, preview_public_playlist};
|
||||
pub use models::{
|
||||
ImportPlaylist, ImportProgress, ImportProvider, ImportSummary, ImportTrack, TrackMatch,
|
||||
};
|
||||
pub use providers::{detect_music_resource, MusicProvider, MusicResource};
|
||||
pub use sink::{ImportEvent, ImportPhase, ImportProgressSink};
|
||||
|
||||
/// Cloudflare Workers proxy that holds the third-party API credentials.
|
||||
/// Hoisted from the Tidal provider so the future link-resolver port shares
|
||||
/// the one constant instead of duplicating it.
|
||||
pub const QBZ_PROXY_BASE: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev";
|
||||
|
||||
/// Provider key for the UI gate ("spotify" | "apple" | "tidal" | "deezer").
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderKey {
|
||||
Spotify,
|
||||
Apple,
|
||||
Tidal,
|
||||
Deezer,
|
||||
}
|
||||
|
||||
impl ProviderKey {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ProviderKey::Spotify => "spotify",
|
||||
ProviderKey::Apple => "apple",
|
||||
ProviderKey::Tidal => "tidal",
|
||||
ProviderKey::Deezer => "deezer",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// UI-gate provider detection — the exact substring rules of the Svelte
|
||||
/// `detectProvider` (looser than [`providers::detect_provider`], which stays
|
||||
/// the authoritative backend validation and may reject what this gate
|
||||
/// passed). Kept here so the UI enable/disable logic and the backend share
|
||||
/// one source of truth.
|
||||
pub fn detect_provider_key(url: &str) -> Option<ProviderKey> {
|
||||
let url = url.trim();
|
||||
|
||||
if url.starts_with("spotify:playlist:")
|
||||
|| url.contains("open.spotify.com/playlist/")
|
||||
|| url.contains("open.spotify.com/embed/playlist/")
|
||||
{
|
||||
return Some(ProviderKey::Spotify);
|
||||
}
|
||||
if url.contains("music.apple.com/") && url.contains("/playlist/") {
|
||||
return Some(ProviderKey::Apple);
|
||||
}
|
||||
if url.contains("tidal.com/") && url.contains("/playlist/") {
|
||||
return Some(ProviderKey::Tidal);
|
||||
}
|
||||
if url.contains("deezer.com/") && url.contains("/playlist/") {
|
||||
return Some(ProviderKey::Deezer);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detect_provider_key_table() {
|
||||
let cases: &[(&str, Option<ProviderKey>)] = &[
|
||||
// Spotify: URI, URL, embed
|
||||
(
|
||||
"spotify:playlist:37i9dQZF1DXcBWIGoYBM5M",
|
||||
Some(ProviderKey::Spotify),
|
||||
),
|
||||
(
|
||||
"https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M",
|
||||
Some(ProviderKey::Spotify),
|
||||
),
|
||||
(
|
||||
"https://open.spotify.com/embed/playlist/37i9dQZF1DXcBWIGoYBM5M",
|
||||
Some(ProviderKey::Spotify),
|
||||
),
|
||||
// Apple: needs both music.apple.com/ AND /playlist/
|
||||
(
|
||||
"https://music.apple.com/us/playlist/top-100/pl.123",
|
||||
Some(ProviderKey::Apple),
|
||||
),
|
||||
("https://music.apple.com/us/album/x/123", None),
|
||||
// Tidal
|
||||
(
|
||||
"https://tidal.com/browse/playlist/abc-def",
|
||||
Some(ProviderKey::Tidal),
|
||||
),
|
||||
("https://tidal.com/browse/album/123", None),
|
||||
// Deezer
|
||||
(
|
||||
"https://www.deezer.com/en/playlist/1234567",
|
||||
Some(ProviderKey::Deezer),
|
||||
),
|
||||
("https://www.deezer.com/en/album/1234567", None),
|
||||
// Rejects
|
||||
("https://open.spotify.com/track/abc", None),
|
||||
("https://example.com/playlist/1", None),
|
||||
("", None),
|
||||
];
|
||||
|
||||
for (url, expected) in cases {
|
||||
assert_eq!(detect_provider_key(url), *expected, "url: {}", url);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_provider_key_trims_whitespace() {
|
||||
assert_eq!(
|
||||
detect_provider_key(" spotify:playlist:abc "),
|
||||
Some(ProviderKey::Spotify)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_key_as_str() {
|
||||
assert_eq!(ProviderKey::Spotify.as_str(), "spotify");
|
||||
assert_eq!(ProviderKey::Apple.as_str(), "apple");
|
||||
assert_eq!(ProviderKey::Tidal.as_str(), "tidal");
|
||||
assert_eq!(ProviderKey::Deezer.as_str(), "deezer");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
//! Match imported tracks to Qobuz catalog
|
||||
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::stream::{self, StreamExt};
|
||||
use qbz_models::Track;
|
||||
use qbz_qobuz::QobuzClient;
|
||||
|
||||
use crate::errors::PlaylistImportError;
|
||||
use crate::models::{ImportProgress, ImportTrack, TrackMatch};
|
||||
use crate::sink::{ImportEvent, ImportProgressSink};
|
||||
|
||||
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;
|
||||
const CONCURRENCY: usize = 8;
|
||||
|
||||
pub async fn match_tracks(
|
||||
client: &QobuzClient,
|
||||
tracks: &[ImportTrack],
|
||||
progress: Arc<dyn ImportProgressSink>,
|
||||
) -> Result<Vec<TrackMatch>, PlaylistImportError> {
|
||||
let total = tracks.len() as u32;
|
||||
let matched_counter = Arc::new(AtomicU32::new(0));
|
||||
let completed_counter = Arc::new(AtomicU32::new(0));
|
||||
|
||||
// Pre-allocate results vector with None slots
|
||||
let results: Arc<tokio::sync::Mutex<Vec<Option<TrackMatch>>>> =
|
||||
Arc::new(tokio::sync::Mutex::new(vec![None; tracks.len()]));
|
||||
|
||||
let owned_tracks: Vec<(usize, ImportTrack)> = tracks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, tr)| (i, tr.clone()))
|
||||
.collect();
|
||||
|
||||
stream::iter(owned_tracks)
|
||||
.map(|(idx, track)| {
|
||||
let client = client.clone();
|
||||
let progress = Arc::clone(&progress);
|
||||
let matched_counter = Arc::clone(&matched_counter);
|
||||
let completed_counter = Arc::clone(&completed_counter);
|
||||
let results = Arc::clone(&results);
|
||||
|
||||
async move {
|
||||
let query = format!("{} {}", track.artist, track.title);
|
||||
let search_result = client.search_tracks(&query, SEARCH_LIMIT, 0, None).await;
|
||||
|
||||
let match_entry = match search_result {
|
||||
Ok(search) => {
|
||||
let (best, score) = select_best_match(&track, &search.items);
|
||||
match best {
|
||||
Some(candidate) if score >= MIN_SCORE => {
|
||||
matched_counter.fetch_add(1, Ordering::Relaxed);
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Search failed for '{}' - '{}': {}",
|
||||
track.artist,
|
||||
track.title,
|
||||
e
|
||||
);
|
||||
TrackMatch {
|
||||
source: track.clone(),
|
||||
qobuz_track_id: None,
|
||||
qobuz_title: None,
|
||||
qobuz_artist: None,
|
||||
score: 0.0,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Store result at correct index
|
||||
{
|
||||
let mut res = results.lock().await;
|
||||
res[idx] = Some(match_entry);
|
||||
}
|
||||
|
||||
let current = completed_counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let matched = matched_counter.load(Ordering::Relaxed);
|
||||
|
||||
let current_track = Some(format!("{} - {}", track.artist, track.title));
|
||||
|
||||
progress.emit(ImportEvent::Progress(ImportProgress {
|
||||
phase: "matching".to_string(),
|
||||
current,
|
||||
total,
|
||||
matched_so_far: matched,
|
||||
current_track,
|
||||
}));
|
||||
}
|
||||
})
|
||||
.buffer_unordered(CONCURRENCY)
|
||||
.collect::<Vec<()>>()
|
||||
.await;
|
||||
|
||||
// Extract results in order
|
||||
let locked = results.lock().await;
|
||||
let ordered: Vec<TrackMatch> = locked
|
||||
.iter()
|
||||
.map(|slot| slot.clone().expect("All slots should be filled"))
|
||||
.collect();
|
||||
|
||||
Ok(ordered)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use qbz_models::{AlbumSummary, Artist, ImageSet};
|
||||
|
||||
fn import_track(title: &str, artist: &str) -> ImportTrack {
|
||||
ImportTrack {
|
||||
title: title.to_string(),
|
||||
artist: artist.to_string(),
|
||||
album: None,
|
||||
duration_ms: None,
|
||||
isrc: None,
|
||||
provider_id: None,
|
||||
provider_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn qobuz_track(id: u64, title: &str, artist: &str) -> Track {
|
||||
Track {
|
||||
id,
|
||||
title: title.to_string(),
|
||||
version: None,
|
||||
isrc: None,
|
||||
duration: 0,
|
||||
track_number: 0,
|
||||
media_number: None,
|
||||
performer: Some(Artist {
|
||||
name: artist.to_string(),
|
||||
..Artist::default()
|
||||
}),
|
||||
album: None,
|
||||
hires: false,
|
||||
hires_streamable: false,
|
||||
maximum_sampling_rate: None,
|
||||
maximum_bit_depth: None,
|
||||
streamable: true,
|
||||
parental_warning: false,
|
||||
playlist_track_id: None,
|
||||
performers: None,
|
||||
composer: None,
|
||||
copyright: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn album_summary(title: &str) -> AlbumSummary {
|
||||
AlbumSummary {
|
||||
id: String::new(),
|
||||
title: title.to_string(),
|
||||
image: ImageSet::default(),
|
||||
label: None,
|
||||
genre: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── normalize / remove_bracketed / token_overlap ──
|
||||
|
||||
#[test]
|
||||
fn normalize_strips_brackets_and_stop_words() {
|
||||
assert_eq!(normalize("Song Title (Remastered 2011)"), "song title");
|
||||
assert_eq!(normalize("Song Title [Deluxe Edition]"), "song title");
|
||||
assert_eq!(normalize("Hey Jude - Remastered"), "hey jude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_handles_nested_brackets() {
|
||||
assert_eq!(normalize("Title (Live [2020] Version)"), "title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_degrades_non_ascii_to_spaces() {
|
||||
// Accented/CJK chars are not ASCII-alphanumeric → replaced by spaces.
|
||||
// Locks the (lossy) accented-title behavior of the Tauri original.
|
||||
assert_eq!(normalize("Café Tacvba"), "caf tacvba");
|
||||
assert_eq!(normalize("너의 의미"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_lowercases_and_collapses_punctuation() {
|
||||
assert_eq!(normalize("AC/DC - T.N.T."), "ac dc t n t");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_bracketed_basic_and_nested() {
|
||||
assert_eq!(remove_bracketed("a (b) c"), "a c");
|
||||
assert_eq!(remove_bracketed("a (b [c] d) e"), "a e");
|
||||
assert_eq!(remove_bracketed("no brackets"), "no brackets");
|
||||
// Unbalanced closers are ignored at depth 0
|
||||
assert_eq!(remove_bracketed(") leading"), " leading");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_overlap_ratio_uses_longer_side() {
|
||||
assert_eq!(token_overlap("a b", "a b"), 1.0);
|
||||
assert_eq!(token_overlap("a b", "a b c d"), 0.5);
|
||||
assert_eq!(token_overlap("x", "y"), 0.0);
|
||||
assert_eq!(token_overlap("", "a"), 0.0);
|
||||
}
|
||||
|
||||
// ── similarity ──
|
||||
|
||||
#[test]
|
||||
fn similarity_exact_after_normalization_is_one() {
|
||||
assert_eq!(similarity("Hey Jude (Remastered)", "hey jude"), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn similarity_substring_is_085() {
|
||||
assert_eq!(similarity("hey jude", "hey jude na na"), 0.85);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn similarity_falls_back_to_token_overlap() {
|
||||
// "hey there" vs "jude there": 1 shared token / max(2, 2) = 0.5
|
||||
assert_eq!(similarity("hey there", "jude there"), 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn similarity_empty_is_zero() {
|
||||
assert_eq!(similarity("", "anything"), 0.0);
|
||||
assert_eq!(similarity("anything", ""), 0.0);
|
||||
}
|
||||
|
||||
// ── score_candidate ──
|
||||
|
||||
#[test]
|
||||
fn score_candidate_isrc_short_circuit_is_case_insensitive() {
|
||||
let mut source = import_track("Completely Different", "Nobody");
|
||||
source.isrc = Some("uskO11600123".to_string());
|
||||
let mut candidate = qobuz_track(1, "Other Title", "Other Artist");
|
||||
candidate.isrc = Some("USKO11600123".to_string());
|
||||
assert_eq!(score_candidate(&source, &candidate), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_candidate_weights_title_artist_album() {
|
||||
let mut source = import_track("hey jude", "the beatles");
|
||||
source.album = Some("past masters".to_string());
|
||||
let mut candidate = qobuz_track(1, "hey jude", "the beatles");
|
||||
candidate.album = Some(album_summary("past masters"));
|
||||
// duration_ms is None on the source → no duration bonus, so the score
|
||||
// is exactly the 0.6/0.3/0.1 weighted sum (all components 1.0 here).
|
||||
let score = score_candidate(&source, &candidate);
|
||||
assert!((score - 1.0).abs() < 1e-6, "got {}", score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_candidate_duration_bonus_tiers() {
|
||||
let mut source = import_track("hey jude", "the beatles");
|
||||
let mut candidate = qobuz_track(1, "hey jude", "the beatles");
|
||||
candidate.duration = 200; // 200_000 ms
|
||||
|
||||
// No source duration → no bonus (source-duration-only quirk).
|
||||
let base = score_candidate(&source, &candidate);
|
||||
assert!((base - 0.9).abs() < 1e-6, "got {}", base);
|
||||
|
||||
// Within 3s → +0.05
|
||||
source.duration_ms = Some(202_000);
|
||||
let close = score_candidate(&source, &candidate);
|
||||
assert!((close - 0.95).abs() < 1e-6, "got {}", close);
|
||||
|
||||
// Within 5s → +0.02
|
||||
source.duration_ms = Some(204_500);
|
||||
let near = score_candidate(&source, &candidate);
|
||||
assert!((near - 0.92).abs() < 1e-6, "got {}", near);
|
||||
|
||||
// Beyond 5s → no bonus
|
||||
source.duration_ms = Some(210_000);
|
||||
let far = score_candidate(&source, &candidate);
|
||||
assert!((far - 0.9).abs() < 1e-6, "got {}", far);
|
||||
}
|
||||
|
||||
// ── select_best_match ──
|
||||
|
||||
#[test]
|
||||
fn select_best_match_skips_non_streamable() {
|
||||
let source = import_track("hey jude", "the beatles");
|
||||
let mut perfect = qobuz_track(1, "hey jude", "the beatles");
|
||||
perfect.streamable = false;
|
||||
let weaker = qobuz_track(2, "hey jude na na", "the beatles");
|
||||
|
||||
let candidates = [perfect, weaker];
|
||||
let (best, _score) = select_best_match(&source, &candidates);
|
||||
assert_eq!(best.map(|t| t.id), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_best_match_score_below_min_score_threshold() {
|
||||
// select_best_match does NOT gate on MIN_SCORE — the CALLER
|
||||
// (match_tracks) drops sub-threshold matches. Prove a PARTIAL match
|
||||
// (same artist, different title → ~0.3, well under 0.65) is still
|
||||
// RETURNED, leaving the rejection to the caller. NOTE: it must be a
|
||||
// partial, not pure junk — a fully disjoint candidate scores 0.0,
|
||||
// never beats the 0.0 seed (`score > best_score + 0.0001`), and comes
|
||||
// back as None, which is a different code path.
|
||||
let source = import_track("hey jude", "the beatles");
|
||||
let partial = qobuz_track(1, "let it be", "the beatles");
|
||||
let candidates = [partial];
|
||||
let (best, score) = select_best_match(&source, &candidates);
|
||||
assert!(best.is_some(), "a partial match must still be returned");
|
||||
assert!(score > 0.0 && score < MIN_SCORE, "got {}", score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_best_match_hi_res_tiebreak_within_001() {
|
||||
let source = import_track("hey jude", "the beatles");
|
||||
let mut cd = qobuz_track(1, "hey jude", "the beatles");
|
||||
cd.maximum_bit_depth = Some(16);
|
||||
cd.maximum_sampling_rate = Some(44.1);
|
||||
let mut hires = qobuz_track(2, "hey jude", "the beatles");
|
||||
hires.maximum_bit_depth = Some(24);
|
||||
hires.maximum_sampling_rate = Some(192.0);
|
||||
|
||||
// Equal scores → quality_score decides, regardless of order.
|
||||
let ordered = [cd.clone(), hires.clone()];
|
||||
let (best, _) = select_best_match(&source, &ordered);
|
||||
assert_eq!(best.map(|t| t.id), Some(2));
|
||||
let reversed = [hires, cd];
|
||||
let (best, _) = select_best_match(&source, &reversed);
|
||||
assert_eq!(best.map(|t| t.id), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_best_match_empty_candidates() {
|
||||
let source = import_track("hey jude", "the beatles");
|
||||
let (best, score) = select_best_match(&source, &[]);
|
||||
assert!(best.is_none());
|
||||
assert_eq!(score, 0.0);
|
||||
}
|
||||
|
||||
// ── quality_score ──
|
||||
|
||||
#[test]
|
||||
fn quality_score_weighs_bit_depth_over_sample_rate() {
|
||||
let mut a = qobuz_track(1, "x", "y");
|
||||
a.maximum_bit_depth = Some(24);
|
||||
a.maximum_sampling_rate = Some(44.1);
|
||||
let mut b = qobuz_track(2, "x", "y");
|
||||
b.maximum_bit_depth = Some(16);
|
||||
b.maximum_sampling_rate = Some(192.0);
|
||||
assert!(quality_score(&a) > quality_score(&b));
|
||||
|
||||
let none = qobuz_track(3, "x", "y");
|
||||
assert_eq!(quality_score(&none), 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! 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_ids: Vec<u64>,
|
||||
pub parts_created: u32,
|
||||
pub matches: Vec<TrackMatch>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImportProgress {
|
||||
pub phase: String,
|
||||
pub current: u32,
|
||||
pub total: u32,
|
||||
pub matched_so_far: u32,
|
||||
pub current_track: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
//! Apple Music playlist import
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::errors::PlaylistImportError;
|
||||
use crate::http::http;
|
||||
use crate::models::{ImportPlaylist, ImportProvider, ImportTrack};
|
||||
|
||||
/// Detect if a URL is an Apple Music track, album, or playlist.
|
||||
///
|
||||
/// Apple Music URLs:
|
||||
/// - Track: `music.apple.com/{storefront}/album/{name}/{id}?i={track_id}`
|
||||
/// - Album: `music.apple.com/{storefront}/album/{name}/{id}` (no `?i=`)
|
||||
/// - Playlist: `music.apple.com/{storefront}/playlist/{name}/{pl.xxx}`
|
||||
/// - Song: `music.apple.com/{storefront}/song/{name}/{id}`
|
||||
pub fn detect_resource(url: &str) -> Option<super::MusicResource> {
|
||||
if !url.contains("music.apple.com/") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Playlist
|
||||
if parse_playlist_id(url).is_some() {
|
||||
return Some(super::MusicResource::Playlist {
|
||||
provider: super::MusicProvider::AppleMusic,
|
||||
});
|
||||
}
|
||||
|
||||
// Song page (explicit song URL)
|
||||
if url.contains("/song/") {
|
||||
return Some(super::MusicResource::Track {
|
||||
provider: super::MusicProvider::AppleMusic,
|
||||
url: url.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Album page — with ?i= parameter means specific track
|
||||
if url.contains("/album/") {
|
||||
if url.contains("?i=") || url.contains("&i=") {
|
||||
return Some(super::MusicResource::Track {
|
||||
provider: super::MusicProvider::AppleMusic,
|
||||
url: url.to_string(),
|
||||
});
|
||||
}
|
||||
return Some(super::MusicResource::Album {
|
||||
provider: super::MusicProvider::AppleMusic,
|
||||
url: url.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
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 = http()
|
||||
.get(&url)
|
||||
.send()
|
||||
.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(">", ">")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_playlist_id_table() {
|
||||
// Editorial pl. id
|
||||
assert_eq!(
|
||||
parse_playlist_id(
|
||||
"https://music.apple.com/us/playlist/top-100-global/pl.d25f5d1181894928af76c85c967f8f31"
|
||||
),
|
||||
Some((
|
||||
"us".to_string(),
|
||||
"pl.d25f5d1181894928af76c85c967f8f31".to_string()
|
||||
))
|
||||
);
|
||||
// User pl.u- id + query strip
|
||||
assert_eq!(
|
||||
parse_playlist_id("https://music.apple.com/mx/playlist/mias/pl.u-abc123?l=en"),
|
||||
Some(("mx".to_string(), "pl.u-abc123".to_string())),
|
||||
);
|
||||
// Album URL is not a playlist
|
||||
assert_eq!(
|
||||
parse_playlist_id("https://music.apple.com/us/album/abbey-road/1441164426"),
|
||||
None
|
||||
);
|
||||
// Too few path segments
|
||||
assert_eq!(
|
||||
parse_playlist_id("https://music.apple.com/us/playlist"),
|
||||
None
|
||||
);
|
||||
// Wrong host
|
||||
assert_eq!(
|
||||
parse_playlist_id("https://example.com/us/playlist/x/pl.123"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_resource_song_album_track() {
|
||||
assert!(matches!(
|
||||
detect_resource("https://music.apple.com/us/song/hey-jude/1441164589"),
|
||||
Some(super::super::MusicResource::Track { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
detect_resource("https://music.apple.com/us/album/abbey-road/1441164426?i=1441164589"),
|
||||
Some(super::super::MusicResource::Track { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
detect_resource("https://music.apple.com/us/album/abbey-road/1441164426"),
|
||||
Some(super::super::MusicResource::Album { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
detect_resource("https://music.apple.com/us/playlist/x/pl.123"),
|
||||
Some(super::super::MusicResource::Playlist { .. })
|
||||
));
|
||||
assert_eq!(detect_resource("https://example.com/us/album/x/1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_script_unescapes_serialized_server_data() {
|
||||
let html = concat!(
|
||||
"<script type=\"application/json\" id=\"serialized-server-data\">",
|
||||
"[{"itemKind":"trackLockup"}]",
|
||||
"</script>"
|
||||
);
|
||||
assert_eq!(
|
||||
extract_script(html, "serialized-server-data").as_deref(),
|
||||
Some("[{\"itemKind\":\"trackLockup\"}]")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_meta_reads_og_tags() {
|
||||
let html = concat!(
|
||||
"<meta property=\"og:title\" content=\"My Playlist & More\">",
|
||||
"<meta property=\"og:description\" content=\"\">"
|
||||
);
|
||||
assert_eq!(
|
||||
extract_meta(html, "og:title").as_deref(),
|
||||
Some("My Playlist & More")
|
||||
);
|
||||
assert_eq!(extract_meta(html, "og:description").as_deref(), Some(""));
|
||||
assert_eq!(extract_meta(html, "og:image"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unescape_basic_entities() {
|
||||
assert_eq!(
|
||||
unescape_basic(""a" "b" & <c>"),
|
||||
"\"a\" \"b\" & <c>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_track_items_locates_track_lockup_anywhere() {
|
||||
let data: Value = serde_json::from_str(
|
||||
r#"[{"sections":[{"itemKind":"trackLockup","items":[
|
||||
{"title":"Hey Jude","artistName":"The Beatles","duration":431333},
|
||||
{"title":"Let It Be","artistName":"The Beatles","duration":243026}
|
||||
]}]}]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let items = find_track_items(&data).expect("found");
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0]["title"].as_str(), Some("Hey Jude"));
|
||||
assert_eq!(items[1]["artistName"].as_str(), Some("The Beatles"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_track_items_ignores_empty_lockups() {
|
||||
let data: Value = serde_json::from_str(r#"{"itemKind":"trackLockup","items":[]}"#).unwrap();
|
||||
assert!(find_track_items(&data).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Deezer playlist import
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::errors::PlaylistImportError;
|
||||
use crate::http::http;
|
||||
use crate::models::{ImportPlaylist, ImportProvider, ImportTrack};
|
||||
|
||||
/// Detect if a URL is a Deezer track, album, or playlist.
|
||||
///
|
||||
/// Deezer URLs:
|
||||
/// - Track: `deezer.com/track/{id}` or `deezer.com/{locale}/track/{id}`
|
||||
/// - Album: `deezer.com/album/{id}` or `deezer.com/{locale}/album/{id}`
|
||||
/// - Playlist: `deezer.com/playlist/{id}` or `deezer.com/{locale}/playlist/{id}`
|
||||
pub fn detect_resource(url: &str) -> Option<super::MusicResource> {
|
||||
if !url.contains("deezer.com") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Playlist
|
||||
if parse_playlist_id(url).is_some() {
|
||||
return Some(super::MusicResource::Playlist {
|
||||
provider: super::MusicProvider::Deezer,
|
||||
});
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = url.split('/').collect();
|
||||
for (idx, part) in parts.iter().enumerate() {
|
||||
match *part {
|
||||
"track" => {
|
||||
if parts.get(idx + 1).map(|s| !s.is_empty()).unwrap_or(false) {
|
||||
return Some(super::MusicResource::Track {
|
||||
provider: super::MusicProvider::Deezer,
|
||||
url: url.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
"album" => {
|
||||
if parts.get(idx + 1).map(|s| !s.is_empty()).unwrap_or(false) {
|
||||
return Some(super::MusicResource::Album {
|
||||
provider: super::MusicProvider::Deezer,
|
||||
url: url.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
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 = http()
|
||||
.get(&url)
|
||||
.send()
|
||||
.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,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_playlist_id_table() {
|
||||
let cases: &[(&str, Option<&str>)] = &[
|
||||
(
|
||||
"https://www.deezer.com/playlist/1234567890",
|
||||
Some("1234567890"),
|
||||
),
|
||||
(
|
||||
"https://www.deezer.com/en/playlist/1234567890",
|
||||
Some("1234567890"),
|
||||
),
|
||||
(
|
||||
"https://www.deezer.com/en/playlist/1234567890?utm_source=x",
|
||||
Some("1234567890"),
|
||||
),
|
||||
("https://www.deezer.com/en/album/1234567890", None),
|
||||
("https://www.deezer.com/en/playlist/", None),
|
||||
("https://example.com/playlist/123", None),
|
||||
];
|
||||
|
||||
for (url, expected) in cases {
|
||||
assert_eq!(parse_playlist_id(url).as_deref(), *expected, "url: {}", url);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_resource_track_album_playlist() {
|
||||
assert!(matches!(
|
||||
detect_resource("https://www.deezer.com/en/playlist/123"),
|
||||
Some(super::super::MusicResource::Playlist { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
detect_resource("https://www.deezer.com/track/456"),
|
||||
Some(super::super::MusicResource::Track { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
detect_resource("https://www.deezer.com/fr/album/789"),
|
||||
Some(super::super::MusicResource::Album { .. })
|
||||
));
|
||||
assert_eq!(detect_resource("https://example.com/track/1"), None);
|
||||
assert_eq!(detect_resource("https://www.deezer.com/"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Provider implementations
|
||||
|
||||
pub mod apple;
|
||||
pub mod deezer;
|
||||
pub mod spotify;
|
||||
pub mod tidal;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::errors::PlaylistImportError;
|
||||
use crate::models::ImportPlaylist;
|
||||
|
||||
/// Which streaming platform a music link belongs to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum MusicProvider {
|
||||
Spotify,
|
||||
AppleMusic,
|
||||
Tidal,
|
||||
Deezer,
|
||||
}
|
||||
|
||||
/// The kind of resource a music URL points to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum MusicResource {
|
||||
/// A native Qobuz URL — resolve directly.
|
||||
Qobuz,
|
||||
/// A single track on a third-party platform.
|
||||
Track {
|
||||
provider: MusicProvider,
|
||||
url: String,
|
||||
},
|
||||
/// An album on a third-party platform.
|
||||
Album {
|
||||
provider: MusicProvider,
|
||||
url: String,
|
||||
},
|
||||
/// A playlist — should be redirected to the Playlist Importer.
|
||||
Playlist { provider: MusicProvider },
|
||||
/// A song.link / album.link / odesli.co URL — resolve via Odesli API.
|
||||
SongLink { url: String },
|
||||
}
|
||||
|
||||
/// Detect what kind of music resource a URL points to.
|
||||
///
|
||||
/// Returns `None` for URLs that don't match any supported platform.
|
||||
pub fn detect_music_resource(url: &str) -> Option<MusicResource> {
|
||||
let url = url.trim();
|
||||
if url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 1. Qobuz — resolve_link() handles this natively
|
||||
if qbz_qobuz::resolve_link(url).is_ok() {
|
||||
return Some(MusicResource::Qobuz);
|
||||
}
|
||||
|
||||
// 2. song.link / album.link / odesli.co URLs
|
||||
let lower = url.to_ascii_lowercase();
|
||||
if lower.contains("song.link/") || lower.contains("album.link/") || lower.contains("odesli.co/")
|
||||
{
|
||||
return Some(MusicResource::SongLink {
|
||||
url: url.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Per-provider detection (track/album/playlist)
|
||||
if let Some(resource) = spotify::detect_resource(url) {
|
||||
return Some(resource);
|
||||
}
|
||||
if let Some(resource) = apple::detect_resource(url) {
|
||||
return Some(resource);
|
||||
}
|
||||
if let Some(resource) = tidal::detect_resource(url) {
|
||||
return Some(resource);
|
||||
}
|
||||
if let Some(resource) = deezer::detect_resource(url) {
|
||||
return Some(resource);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[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()))
|
||||
}
|
||||
|
||||
/// Fetch playlist (proxy handles credentials)
|
||||
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,
|
||||
// Default Tidal storefront ("US"); callers wanting another country
|
||||
// call tidal::fetch_playlist directly (e.g. with an env read at
|
||||
// their edge — the Tauri original read TIDAL_COUNTRY_CODE here).
|
||||
ProviderKind::Tidal { playlist_id } => tidal::fetch_playlist(&playlist_id, None).await,
|
||||
ProviderKind::Deezer { playlist_id } => deezer::fetch_playlist(&playlist_id).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detect_provider_table() {
|
||||
// Spotify URI / URL / embed forms + query strip
|
||||
assert_eq!(
|
||||
detect_provider("spotify:playlist:37i9dQZF1DXcBWIGoYBM5M").unwrap(),
|
||||
ProviderKind::Spotify {
|
||||
playlist_id: "37i9dQZF1DXcBWIGoYBM5M".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
detect_provider("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M?si=abc")
|
||||
.unwrap(),
|
||||
ProviderKind::Spotify {
|
||||
playlist_id: "37i9dQZF1DXcBWIGoYBM5M".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
detect_provider("https://open.spotify.com/embed/playlist/37i9dQZF1DXcBWIGoYBM5M")
|
||||
.unwrap(),
|
||||
ProviderKind::Spotify {
|
||||
playlist_id: "37i9dQZF1DXcBWIGoYBM5M".to_string()
|
||||
}
|
||||
);
|
||||
|
||||
// Apple storefront + pl. ids
|
||||
assert_eq!(
|
||||
detect_provider("https://music.apple.com/us/playlist/top-100-global/pl.d25f5d1181894928af76c85c967f8f31")
|
||||
.unwrap(),
|
||||
ProviderKind::AppleMusic {
|
||||
storefront: "us".to_string(),
|
||||
playlist_id: "pl.d25f5d1181894928af76c85c967f8f31".to_string()
|
||||
}
|
||||
);
|
||||
|
||||
// Tidal
|
||||
assert_eq!(
|
||||
detect_provider(
|
||||
"https://tidal.com/browse/playlist/1b418bb8-90a7-4f87-901d-707993838346"
|
||||
)
|
||||
.unwrap(),
|
||||
ProviderKind::Tidal {
|
||||
playlist_id: "1b418bb8-90a7-4f87-901d-707993838346".to_string()
|
||||
}
|
||||
);
|
||||
|
||||
// Deezer
|
||||
assert_eq!(
|
||||
detect_provider("https://www.deezer.com/en/playlist/1234567890").unwrap(),
|
||||
ProviderKind::Deezer {
|
||||
playlist_id: "1234567890".to_string()
|
||||
}
|
||||
);
|
||||
|
||||
// Rejects
|
||||
assert!(detect_provider("https://example.com/playlist/1").is_err());
|
||||
assert!(detect_provider("https://open.spotify.com/track/abc").is_err());
|
||||
assert!(detect_provider("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_music_resource_song_link_and_none() {
|
||||
assert_eq!(
|
||||
detect_music_resource("https://song.link/i/1440857781"),
|
||||
Some(MusicResource::SongLink {
|
||||
url: "https://song.link/i/1440857781".to_string()
|
||||
})
|
||||
);
|
||||
assert_eq!(detect_music_resource(""), None);
|
||||
assert_eq!(detect_music_resource("https://example.com/whatever"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_music_resource_playlist_routes_to_importer() {
|
||||
assert_eq!(
|
||||
detect_music_resource("https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M"),
|
||||
Some(MusicResource::Playlist {
|
||||
provider: MusicProvider::Spotify
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
//! Spotify playlist import
|
||||
//!
|
||||
//! As of 2026-03-06, Spotify API access via client_credentials is no longer available.
|
||||
//! All playlist imports now use the embed page scraping fallback.
|
||||
//! Embed is limited to ~50 tracks and provides no ISRC or album data.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::errors::PlaylistImportError;
|
||||
use crate::http::http;
|
||||
use crate::models::{ImportPlaylist, ImportProvider, ImportTrack};
|
||||
|
||||
/// Detect if a URL is a Spotify track, album, or playlist.
|
||||
pub fn detect_resource(url: &str) -> Option<super::MusicResource> {
|
||||
let lower = url.to_ascii_lowercase();
|
||||
if !lower.contains("spotify.com/") && !lower.starts_with("spotify:") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Playlist check first (so parse_playlist_id takes priority for playlists)
|
||||
if parse_playlist_id(url).is_some() {
|
||||
return Some(super::MusicResource::Playlist {
|
||||
provider: super::MusicProvider::Spotify,
|
||||
});
|
||||
}
|
||||
|
||||
// Track: open.spotify.com/track/<id> or spotify:track:<id>
|
||||
if lower.contains("/track/") || lower.contains(":track:") {
|
||||
return Some(super::MusicResource::Track {
|
||||
provider: super::MusicProvider::Spotify,
|
||||
url: url.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Album: open.spotify.com/album/<id> or spotify:album:<id>
|
||||
if lower.contains("/album/") || lower.contains(":album:") {
|
||||
return Some(super::MusicResource::Album {
|
||||
provider: super::MusicProvider::Spotify,
|
||||
url: url.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// Fetch track or album metadata from Spotify embed page.
|
||||
/// Returns (title, artist) if successful.
|
||||
pub async fn fetch_embed_metadata(entity_type: &str, entity_id: &str) -> Option<(String, String)> {
|
||||
let url = format!(
|
||||
"https://open.spotify.com/embed/{}/{}",
|
||||
entity_type, entity_id
|
||||
);
|
||||
let html = http().get(&url).send().await.ok()?.text().await.ok()?;
|
||||
let json_text = extract_script(&html, "__NEXT_DATA__")?;
|
||||
let data: Value = serde_json::from_str(&json_text).ok()?;
|
||||
|
||||
let entity = data
|
||||
.get("props")?
|
||||
.get("pageProps")?
|
||||
.get("state")?
|
||||
.get("data")?
|
||||
.get("entity")?;
|
||||
|
||||
let title = entity
|
||||
.get("title")
|
||||
.or_else(|| entity.get("name"))
|
||||
.and_then(|v| v.as_str())?
|
||||
.to_string();
|
||||
|
||||
// Tracks have "artists" array, albums have "subtitle" string
|
||||
let artist = entity
|
||||
.get("artists")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|a| a.get("name").and_then(|v| v.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
})
|
||||
.or_else(|| {
|
||||
entity
|
||||
.get("subtitle")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if title.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((title, artist))
|
||||
}
|
||||
|
||||
pub async fn fetch_playlist(playlist_id: &str) -> Result<ImportPlaylist, PlaylistImportError> {
|
||||
log::info!(
|
||||
"Spotify: fetching playlist {} via embed (API no longer available)",
|
||||
playlist_id
|
||||
);
|
||||
fetch_playlist_from_embed(playlist_id).await
|
||||
}
|
||||
|
||||
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 = http()
|
||||
.get(&url)
|
||||
.send()
|
||||
.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,
|
||||
});
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Spotify: embed returned {} tracks for '{}' (embed limit is ~50, no ISRC/album data)",
|
||||
tracks.len(),
|
||||
name
|
||||
);
|
||||
|
||||
Ok(ImportPlaylist {
|
||||
provider: ImportProvider::Spotify,
|
||||
provider_id: playlist_id.to_string(),
|
||||
name,
|
||||
description: None,
|
||||
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;
|
||||
Some(html[script_start..script_end].to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_playlist_id_table() {
|
||||
let cases: &[(&str, Option<&str>)] = &[
|
||||
(
|
||||
"spotify:playlist:37i9dQZF1DXcBWIGoYBM5M",
|
||||
Some("37i9dQZF1DXcBWIGoYBM5M"),
|
||||
),
|
||||
(
|
||||
"https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M",
|
||||
Some("37i9dQZF1DXcBWIGoYBM5M"),
|
||||
),
|
||||
(
|
||||
"https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M?si=xyz&utm=1",
|
||||
Some("37i9dQZF1DXcBWIGoYBM5M"),
|
||||
),
|
||||
(
|
||||
"https://open.spotify.com/embed/playlist/37i9dQZF1DXcBWIGoYBM5M",
|
||||
Some("37i9dQZF1DXcBWIGoYBM5M"),
|
||||
),
|
||||
("spotify:playlist:", None),
|
||||
("https://open.spotify.com/playlist/", None),
|
||||
("https://open.spotify.com/track/abc", None),
|
||||
("https://example.com/", None),
|
||||
];
|
||||
|
||||
for (url, expected) in cases {
|
||||
assert_eq!(parse_playlist_id(url).as_deref(), *expected, "url: {}", url);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_resource_track_album_playlist() {
|
||||
assert_eq!(
|
||||
detect_resource("https://open.spotify.com/playlist/abc"),
|
||||
Some(super::super::MusicResource::Playlist {
|
||||
provider: super::super::MusicProvider::Spotify
|
||||
})
|
||||
);
|
||||
assert!(matches!(
|
||||
detect_resource("https://open.spotify.com/track/abc"),
|
||||
Some(super::super::MusicResource::Track { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
detect_resource("spotify:album:abc"),
|
||||
Some(super::super::MusicResource::Album { .. })
|
||||
));
|
||||
assert_eq!(detect_resource("https://example.com/track/abc"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_script_pulls_next_data_payload() {
|
||||
let html = concat!(
|
||||
"<html><head></head><body>",
|
||||
"<script id=\"__NEXT_DATA__\" type=\"application/json\">",
|
||||
"{\"props\":{\"pageProps\":{\"state\":{\"data\":{\"entity\":{\"title\":\"My Mix\"}}}}}}",
|
||||
"</script></body></html>"
|
||||
);
|
||||
let json_text = extract_script(html, "__NEXT_DATA__").expect("script found");
|
||||
let data: Value = serde_json::from_str(&json_text).expect("valid JSON");
|
||||
let title = data["props"]["pageProps"]["state"]["data"]["entity"]["title"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
assert_eq!(title, "My Mix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_script_missing_id_is_none() {
|
||||
assert_eq!(extract_script("<html></html>", "__NEXT_DATA__"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
//! Tidal playlist import (OpenAPI v2)
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::Value;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::errors::PlaylistImportError;
|
||||
use crate::http::http;
|
||||
use crate::models::{ImportPlaylist, ImportProvider, ImportTrack};
|
||||
|
||||
const RATE_LIMIT_DELAY_MS: u64 = 200; // Delay between API calls to avoid 429
|
||||
|
||||
const TIDAL_API_BASE: &str = "https://openapi.tidal.com/v2";
|
||||
const DEFAULT_COUNTRY_CODE: &str = "US";
|
||||
|
||||
/// Detect if a URL is a Tidal track, album, or playlist.
|
||||
///
|
||||
/// Tidal URLs:
|
||||
/// - Track: `tidal.com/browse/track/{id}` or `tidal.com/track/{id}`
|
||||
/// - Album: `tidal.com/browse/album/{id}` or `tidal.com/album/{id}`
|
||||
/// - Playlist: `tidal.com/browse/playlist/{id}` or `tidal.com/playlist/{id}`
|
||||
pub fn detect_resource(url: &str) -> Option<super::MusicResource> {
|
||||
if !url.contains("tidal.com") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Playlist
|
||||
if parse_playlist_id(url).is_some() {
|
||||
return Some(super::MusicResource::Playlist {
|
||||
provider: super::MusicProvider::Tidal,
|
||||
});
|
||||
}
|
||||
|
||||
let lower = url.to_ascii_lowercase();
|
||||
|
||||
// Track
|
||||
if lower.contains("/track/") || lower.contains("/browse/track/") {
|
||||
return Some(super::MusicResource::Track {
|
||||
provider: super::MusicProvider::Tidal,
|
||||
url: url.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Album
|
||||
if lower.contains("/album/") || lower.contains("/browse/album/") {
|
||||
return Some(super::MusicResource::Album {
|
||||
provider: super::MusicProvider::Tidal,
|
||||
url: url.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// Fetch a Tidal playlist.
|
||||
///
|
||||
/// `country_code` replaces the Tauri original's `TIDAL_COUNTRY_CODE` env read
|
||||
/// — `None` keeps the same "US" default; callers wanting the env behavior
|
||||
/// read it at their edge and pass `Some(..)`.
|
||||
pub async fn fetch_playlist(
|
||||
playlist_id: &str,
|
||||
country_code: Option<&str>,
|
||||
) -> Result<ImportPlaylist, PlaylistImportError> {
|
||||
let token = get_app_token().await?;
|
||||
let country_code = country_code.unwrap_or(DEFAULT_COUNTRY_CODE).to_string();
|
||||
|
||||
let client = http();
|
||||
let meta_url = format!("{}/playlists/{}", TIDAL_API_BASE, playlist_id);
|
||||
let resp = client
|
||||
.get(&meta_url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.query(&[("countryCode", &country_code)])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| PlaylistImportError::Http(e.to_string()))?;
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(PlaylistImportError::Http(format!(
|
||||
"Tidal playlist fetch failed: {} - {}",
|
||||
status, body
|
||||
)));
|
||||
}
|
||||
|
||||
let meta: Value = serde_json::from_str(&body)
|
||||
.map_err(|e| PlaylistImportError::Parse(format!("Invalid playlist JSON: {}", e)))?;
|
||||
|
||||
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);
|
||||
sleep(Duration::from_millis(RATE_LIMIT_DELAY_MS)).await;
|
||||
|
||||
let mut request = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", token));
|
||||
if !next_path.contains("countryCode=") {
|
||||
request = request.query(&[("countryCode", country_code)]);
|
||||
}
|
||||
|
||||
let resp = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| PlaylistImportError::Http(e.to_string()))?;
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(PlaylistImportError::Http(format!(
|
||||
"Tidal track IDs fetch failed: {} - {}",
|
||||
status, body
|
||||
)));
|
||||
}
|
||||
|
||||
let response: Value = serde_json::from_str(&body)
|
||||
.map_err(|e| PlaylistImportError::Parse(format!("Invalid track IDs JSON: {}", e)))?;
|
||||
|
||||
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 = 20usize; // Tidal API limit
|
||||
|
||||
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);
|
||||
sleep(Duration::from_millis(RATE_LIMIT_DELAY_MS)).await;
|
||||
|
||||
let resp = 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()))?;
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(PlaylistImportError::Http(format!(
|
||||
"Tidal tracks fetch failed: {} - {}",
|
||||
status, body
|
||||
)));
|
||||
}
|
||||
|
||||
let response: Value = serde_json::from_str(&body)
|
||||
.map_err(|e| PlaylistImportError::Parse(format!("Invalid tracks JSON: {}", e)))?;
|
||||
|
||||
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> {
|
||||
// Proxy handles credentials
|
||||
let url = format!("{}/tidal/token", crate::QBZ_PROXY_BASE);
|
||||
|
||||
let response: Value = http()
|
||||
.get(&url)
|
||||
.header(reqwest::header::USER_AGENT, crate::http::USER_AGENT)
|
||||
.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 proxy missing access_token".to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_playlist_id_table() {
|
||||
let cases: &[(&str, Option<&str>)] = &[
|
||||
(
|
||||
"https://tidal.com/browse/playlist/1b418bb8-90a7-4f87-901d-707993838346",
|
||||
Some("1b418bb8-90a7-4f87-901d-707993838346"),
|
||||
),
|
||||
(
|
||||
"https://listen.tidal.com/playlist/1b418bb8-90a7-4f87-901d-707993838346",
|
||||
Some("1b418bb8-90a7-4f87-901d-707993838346"),
|
||||
),
|
||||
("https://tidal.com/playlist/abc?u=1", Some("abc")),
|
||||
("https://tidal.com/browse/album/123", None),
|
||||
("https://tidal.com/playlist/", None),
|
||||
("https://example.com/playlist/abc", None),
|
||||
];
|
||||
|
||||
for (url, expected) in cases {
|
||||
assert_eq!(parse_playlist_id(url).as_deref(), *expected, "url: {}", url);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_resource_track_album_playlist() {
|
||||
assert!(matches!(
|
||||
detect_resource("https://tidal.com/browse/playlist/abc"),
|
||||
Some(super::super::MusicResource::Playlist { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
detect_resource("https://tidal.com/browse/track/12345"),
|
||||
Some(super::super::MusicResource::Track { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
detect_resource("https://tidal.com/album/67890"),
|
||||
Some(super::super::MusicResource::Album { .. })
|
||||
));
|
||||
assert_eq!(detect_resource("https://example.com/track/1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_duration_ms_iso8601_forms() {
|
||||
assert_eq!(parse_duration_ms("PT3M21S"), Some(201_000));
|
||||
assert_eq!(parse_duration_ms("PT1H2M3S"), Some(3_723_000));
|
||||
assert_eq!(parse_duration_ms("PT45S"), Some(45_000));
|
||||
// No leading P
|
||||
assert_eq!(parse_duration_ms("3M21S"), None);
|
||||
// No digit/unit pairs at all
|
||||
assert_eq!(parse_duration_ms("PT"), None);
|
||||
assert_eq!(parse_duration_ms(""), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Frontend-agnostic progress reporting.
|
||||
//!
|
||||
//! Replaces the Tauri `AppHandle::emit("import:phase" | "import:progress")`
|
||||
//! sites. Emission is fire-and-forget and infallible — the Tauri emits were
|
||||
//! all `let _ =`.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::models::ImportProgress;
|
||||
|
||||
/// Import phase, mirroring the Tauri `import:phase` payloads
|
||||
/// (`{"phase": "matching" | "creating" | "adding"}`).
|
||||
///
|
||||
/// `Creating` and `Adding` re-fire once per created playlist part.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ImportPhase {
|
||||
Matching,
|
||||
Creating,
|
||||
/// NOTE (owner decision, deliberate deviation from the Tauri UI):
|
||||
/// frontends should render the status line during this phase as
|
||||
/// "Adding tracks: {current} / {total}" — the Tauri modal reused the
|
||||
/// "Matching tracks…" string here; do not carry that quirk.
|
||||
Adding,
|
||||
}
|
||||
|
||||
/// One event from the import pipeline, mirroring the two Tauri events.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum ImportEvent {
|
||||
Phase(ImportPhase),
|
||||
/// Existing wire model, unchanged shape. While matching: one per track
|
||||
/// (high frequency), `current_track` = "Artist - Title". While adding:
|
||||
/// one per 50-track chunk (chunk counts, not tracks), `current_track` =
|
||||
/// "Part i/n" iff multi-part.
|
||||
Progress(ImportProgress),
|
||||
}
|
||||
|
||||
/// Receives progress events from [`crate::import_public_playlist`].
|
||||
pub trait ImportProgressSink: Send + Sync {
|
||||
fn emit(&self, event: ImportEvent);
|
||||
}
|
||||
|
||||
impl<F: Fn(ImportEvent) + Send + Sync> ImportProgressSink for F {
|
||||
fn emit(&self, event: ImportEvent) {
|
||||
self(event)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user