refactor(spotify): remove API dependency, use embed scraping only
Spotify client_credentials API access ends 2026-03-06. Remove all API token/proxy code and the paginated fetch_playlist_with_token function. Playlist imports now go straight to embed page scraping (limited to ~50 tracks, no ISRC/album data). Link resolver for Spotify tracks/albums also switched from API to embed page scraping via new fetch_embed_metadata function. Remove Spotify credentials from .env.example since they are no longer needed.
This commit is contained in:
3 files changed
+55
-264
No files matched your search
@@ -14,12 +14,6 @@ LAST_FM_APP_REGISTER=your_lastfm_username
|
|||||||
DISCOGS_API_CLIENT_KEY=your_consumer_key_here
|
DISCOGS_API_CLIENT_KEY=your_consumer_key_here
|
||||||
DISCOGS_API_CLIENT_SECRET=your_consumer_secret_here
|
DISCOGS_API_CLIENT_SECRET=your_consumer_secret_here
|
||||||
|
|
||||||
# Spotify API Configuration (for playlist import)
|
|
||||||
# Create your own Spotify application at: https://developer.spotify.com/dashboard
|
|
||||||
# These credentials are used for importing playlists from Spotify
|
|
||||||
|
|
||||||
SPOTIFY_API_CLIENT_ID=your_client_id_here
|
|
||||||
SPOTIFY_API_CLIENT_SECRET=your_client_secret_here
|
|
||||||
|
|
||||||
# Tidal API Configuration (for playlist import)
|
# Tidal API Configuration (for playlist import)
|
||||||
# Create your own Tidal application at: https://developer.tidal.com/
|
# Create your own Tidal application at: https://developer.tidal.com/
|
||||||
|
|||||||
@@ -2420,25 +2420,9 @@ async fn try_deezer_metadata(url: &str, is_track: bool) -> Option<(String, Strin
|
|||||||
async fn try_spotify_metadata(url: &str, is_track: bool) -> Option<(String, String)> {
|
async fn try_spotify_metadata(url: &str, is_track: bool) -> Option<(String, String)> {
|
||||||
let entity = if is_track { "track" } else { "album" };
|
let entity = if is_track { "track" } else { "album" };
|
||||||
let id = extract_spotify_entity_id(url, entity)?;
|
let id = extract_spotify_entity_id(url, entity)?;
|
||||||
let token = get_proxy_token("spotify").await?;
|
|
||||||
let api_url = format!("https://api.spotify.com/v1/{}s/{}", entity, id);
|
|
||||||
|
|
||||||
log::debug!("Link resolver: Spotify direct API: {}", api_url);
|
log::debug!("Link resolver: Spotify embed scrape for {} {}", entity, id);
|
||||||
let data: serde_json::Value = reqwest::Client::new()
|
crate::playlist_import::providers::spotify::fetch_embed_metadata(entity, &id).await
|
||||||
.get(&api_url)
|
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
|
||||||
.send().await.ok()?
|
|
||||||
.json().await.ok()?;
|
|
||||||
|
|
||||||
let title = data.get("name")?.as_str()?.to_string();
|
|
||||||
let artist = data.get("artists")
|
|
||||||
.and_then(|a| a.as_array())
|
|
||||||
.and_then(|a| a.first())
|
|
||||||
.and_then(|a| a.get("name"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
Some((title, artist))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn try_tidal_metadata(url: &str, is_track: bool) -> Option<(String, String)> {
|
async fn try_tidal_metadata(url: &str, is_track: bool) -> Option<(String, String)> {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
//! Spotify playlist import
|
//! 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 serde_json::Value;
|
||||||
|
|
||||||
use crate::playlist_import::errors::PlaylistImportError;
|
use crate::playlist_import::errors::PlaylistImportError;
|
||||||
use crate::playlist_import::models::{ImportPlaylist, ImportProvider, ImportTrack};
|
use crate::playlist_import::models::{ImportPlaylist, ImportProvider, ImportTrack};
|
||||||
|
|
||||||
// Cloudflare Workers proxy URL - handles credentials
|
|
||||||
const SPOTIFY_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/spotify";
|
|
||||||
const SPOTIFY_API_BASE: &str = "https://api.spotify.com/v1";
|
|
||||||
|
|
||||||
/// Detect if a URL is a Spotify track, album, or playlist.
|
/// Detect if a URL is a Spotify track, album, or playlist.
|
||||||
pub fn detect_resource(url: &str) -> Option<super::MusicResource> {
|
pub fn detect_resource(url: &str) -> Option<super::MusicResource> {
|
||||||
let lower = url.to_ascii_lowercase();
|
let lower = url.to_ascii_lowercase();
|
||||||
@@ -68,199 +68,55 @@ pub fn parse_playlist_id(url: &str) -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn fetch_playlist(playlist_id: &str) -> Result<ImportPlaylist, PlaylistImportError> {
|
/// Fetch track or album metadata from Spotify embed page.
|
||||||
// Try API with token, retry once with fresh token on failure
|
/// Returns (title, artist) if successful.
|
||||||
for attempt in 1..=2 {
|
pub async fn fetch_embed_metadata(entity_type: &str, entity_id: &str) -> Option<(String, String)> {
|
||||||
match get_app_token().await {
|
let url = format!("https://open.spotify.com/embed/{}/{}", entity_type, entity_id);
|
||||||
Ok(token) => {
|
let html = reqwest::get(&url).await.ok()?.text().await.ok()?;
|
||||||
log::info!("Spotify: API attempt {}/2, token obtained", attempt);
|
let json_text = extract_script(&html, "__NEXT_DATA__")?;
|
||||||
match fetch_playlist_with_token(playlist_id, &token).await {
|
let data: Value = serde_json::from_str(&json_text).ok()?;
|
||||||
Ok(playlist) => {
|
|
||||||
log::info!("Spotify: API returned {} tracks", playlist.tracks.len());
|
let entity = data
|
||||||
return Ok(playlist);
|
.get("props")?
|
||||||
}
|
.get("pageProps")?
|
||||||
Err(e) => {
|
.get("state")?
|
||||||
log::warn!("Spotify: API attempt {}/2 failed: {}", attempt, e);
|
.get("data")?
|
||||||
}
|
.get("entity")?;
|
||||||
}
|
|
||||||
}
|
let title = entity
|
||||||
Err(e) => {
|
.get("title")
|
||||||
log::warn!("Spotify: token attempt {}/2 failed: {}", attempt, e);
|
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
log::warn!(
|
Some((title, artist))
|
||||||
"Spotify: API failed after 2 attempts, falling back to embed (limited to ~100 tracks)"
|
}
|
||||||
|
|
||||||
|
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
|
fetch_playlist_from_embed(playlist_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_playlist_with_token(
|
|
||||||
playlist_id: &str,
|
|
||||||
access_token: &str,
|
|
||||||
) -> Result<ImportPlaylist, PlaylistImportError> {
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
let meta_url = format!("{}/playlists/{}", SPOTIFY_API_BASE, playlist_id);
|
|
||||||
let meta: Value = client
|
|
||||||
.get(&meta_url)
|
|
||||||
.header("Authorization", format!("Bearer {}", access_token))
|
|
||||||
.query(&[("fields", "name,description,tracks.total")])
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
|
|
||||||
.error_for_status()
|
|
||||||
.map_err(|e| PlaylistImportError::Http(format!("Spotify metadata: {}", e)))?
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
|
|
||||||
|
|
||||||
let name = meta
|
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("Spotify Playlist")
|
|
||||||
.to_string();
|
|
||||||
let description = meta
|
|
||||||
.get("description")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|v| v.to_string())
|
|
||||||
.filter(|v| !v.is_empty());
|
|
||||||
let expected_total = meta
|
|
||||||
.get("tracks")
|
|
||||||
.and_then(|v| v.get("total"))
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.unwrap_or(0) as usize;
|
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"Spotify: playlist '{}' reports {} tracks",
|
|
||||||
name,
|
|
||||||
expected_total
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut tracks = Vec::new();
|
|
||||||
let mut offset = 0u32;
|
|
||||||
let limit = 100u32;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let tracks_url = format!("{}/playlists/{}/tracks", SPOTIFY_API_BASE, playlist_id);
|
|
||||||
let response: Value = client
|
|
||||||
.get(&tracks_url)
|
|
||||||
.header("Authorization", format!("Bearer {}", access_token))
|
|
||||||
.query(&[
|
|
||||||
("limit", limit.to_string()),
|
|
||||||
("offset", offset.to_string()),
|
|
||||||
("fields", "items(track(name,artists(name),album(name),duration_ms,external_ids,id,external_urls)),next,total".to_string()),
|
|
||||||
])
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
|
|
||||||
.error_for_status()
|
|
||||||
.map_err(|e| PlaylistImportError::Http(format!("Spotify tracks page offset={}: {}", offset, e)))?
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
|
|
||||||
|
|
||||||
let items = response
|
|
||||||
.get("items")
|
|
||||||
.and_then(|v| v.as_array())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
PlaylistImportError::Parse("Spotify tracks missing items".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
for item in items {
|
|
||||||
let track = match item.get("track") {
|
|
||||||
Some(v) if !v.is_null() => v,
|
|
||||||
_ => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
let title = track
|
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("Unknown")
|
|
||||||
.to_string();
|
|
||||||
let artist = join_artists(track.get("artists"));
|
|
||||||
let album = track
|
|
||||||
.get("album")
|
|
||||||
.and_then(|v| v.get("name"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|v| v.to_string());
|
|
||||||
let duration_ms = track.get("duration_ms").and_then(|v| v.as_u64());
|
|
||||||
let isrc = track
|
|
||||||
.get("external_ids")
|
|
||||||
.and_then(|v| v.get("isrc"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|v| v.to_string());
|
|
||||||
let provider_id = track
|
|
||||||
.get("id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|v| v.to_string());
|
|
||||||
let provider_url = track
|
|
||||||
.get("external_urls")
|
|
||||||
.and_then(|v| v.get("spotify"))
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|v| v.to_string());
|
|
||||||
|
|
||||||
tracks.push(ImportTrack {
|
|
||||||
title,
|
|
||||||
artist,
|
|
||||||
album,
|
|
||||||
duration_ms,
|
|
||||||
isrc,
|
|
||||||
provider_id,
|
|
||||||
provider_url,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use both "next" field and total count to decide pagination
|
|
||||||
let has_next = response.get("next").map(|v| !v.is_null()).unwrap_or(false);
|
|
||||||
|
|
||||||
let response_total = response.get("total").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
|
||||||
|
|
||||||
log::debug!(
|
|
||||||
"Spotify: page offset={}, got {} items, total so far={}, response.total={}, has_next={}",
|
|
||||||
offset, items.len(), tracks.len(), response_total, has_next
|
|
||||||
);
|
|
||||||
|
|
||||||
// Stop if: no next page AND we have all tracks (or response says no more)
|
|
||||||
if !has_next && tracks.len() >= response_total {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Safety: also stop if the page returned 0 items to prevent infinite loop
|
|
||||||
if items.is_empty() {
|
|
||||||
log::warn!(
|
|
||||||
"Spotify: empty page at offset {}, stopping pagination",
|
|
||||||
offset
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extra safety: if we already have enough tracks per the total, stop
|
|
||||||
if response_total > 0 && tracks.len() >= response_total {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
offset += limit;
|
|
||||||
}
|
|
||||||
|
|
||||||
if expected_total > 0 && tracks.len() < expected_total {
|
|
||||||
log::warn!(
|
|
||||||
"Spotify: expected {} tracks but only fetched {} — possible pagination issue",
|
|
||||||
expected_total,
|
|
||||||
tracks.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(ImportPlaylist {
|
|
||||||
provider: ImportProvider::Spotify,
|
|
||||||
provider_id: playlist_id.to_string(),
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
tracks,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_playlist_from_embed(
|
async fn fetch_playlist_from_embed(
|
||||||
playlist_id: &str,
|
playlist_id: &str,
|
||||||
) -> Result<ImportPlaylist, PlaylistImportError> {
|
) -> Result<ImportPlaylist, PlaylistImportError> {
|
||||||
@@ -333,6 +189,12 @@ async fn fetch_playlist_from_embed(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Spotify: embed returned {} tracks for '{}' (embed limit is ~50, no ISRC/album data)",
|
||||||
|
tracks.len(),
|
||||||
|
name
|
||||||
|
);
|
||||||
|
|
||||||
Ok(ImportPlaylist {
|
Ok(ImportPlaylist {
|
||||||
provider: ImportProvider::Spotify,
|
provider: ImportProvider::Spotify,
|
||||||
provider_id: playlist_id.to_string(),
|
provider_id: playlist_id.to_string(),
|
||||||
@@ -342,55 +204,6 @@ async fn fetch_playlist_from_embed(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_app_token() -> Result<String, PlaylistImportError> {
|
|
||||||
// Proxy handles credentials
|
|
||||||
let url = format!("{}/token", SPOTIFY_PROXY_URL);
|
|
||||||
|
|
||||||
let response: Value = reqwest::Client::builder()
|
|
||||||
.default_headers({
|
|
||||||
let mut headers = reqwest::header::HeaderMap::new();
|
|
||||||
headers.insert(
|
|
||||||
reqwest::header::USER_AGENT,
|
|
||||||
reqwest::header::HeaderValue::from_static("QBZ/1.0.0"),
|
|
||||||
);
|
|
||||||
headers
|
|
||||||
})
|
|
||||||
.build()
|
|
||||||
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
|
|
||||||
.get(&url)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| PlaylistImportError::Http(format!("Spotify proxy: {}", e)))?
|
|
||||||
.error_for_status()
|
|
||||||
.map_err(|e| PlaylistImportError::Http(format!("Spotify proxy: {}", e)))?
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| PlaylistImportError::Parse(format!("Spotify proxy response: {}", e)))?;
|
|
||||||
|
|
||||||
response
|
|
||||||
.get("access_token")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|v| v.to_string())
|
|
||||||
.ok_or_else(|| PlaylistImportError::Parse("Spotify proxy missing access_token".to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn join_artists(value: Option<&Value>) -> String {
|
|
||||||
let artists = value
|
|
||||||
.and_then(|v| v.as_array())
|
|
||||||
.map(|arr| {
|
|
||||||
arr.iter()
|
|
||||||
.filter_map(|a| a.get("name").and_then(|v| v.as_str()))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
if artists.is_empty() {
|
|
||||||
"Unknown".to_string()
|
|
||||||
} else {
|
|
||||||
artists.join(", ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_script(html: &str, id: &str) -> Option<String> {
|
fn extract_script(html: &str, id: &str) -> Option<String> {
|
||||||
let marker = format!("id=\"{}\"", id);
|
let marker = format!("id=\"{}\"", id);
|
||||||
let start = html.find(&marker)?;
|
let start = html.find(&marker)?;
|
||||||
|
|||||||
Reference in new issue
Block a user