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:
vicrodh committed 2026-06-11 22:03:46 -06:00
1 parent 999ded11ff
commit 3689a5fda8
33 files changed
+5664 -129

No files matched your search

+28
View File
@@ -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 }
+20
View File
@@ -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),
}
+27
View File
@@ -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")
})
}
+136
View File
@@ -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,
})
}
+158
View File
@@ -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);
}
}
+72
View File
@@ -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("&quot;", "\"")
.replace("&#34;", "\"")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
}
#[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\">",
"[{&quot;itemKind&quot;:&quot;trackLockup&quot;}]",
"</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 &amp; 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("&quot;a&quot; &#34;b&#34; &amp; &lt;c&gt;"),
"\"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);
}
}
+47
View File
@@ -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)
}
}