fix(playlist-import): fix Tidal API rate limiting and chunk size
- Add 200ms delay between API calls to avoid 429 Too Many Requests - Reduce chunk size from 50 to 20 (Tidal API limit for filter[id]) - Improve error messages to include response body for debugging
This commit is contained in:
1 parent
25fe2e363f
commit
00a7c97322
1 file changed
+59
-22
@@ -1,14 +1,18 @@
|
|||||||
//! Tidal playlist import (OpenAPI v2)
|
//! Tidal playlist import (OpenAPI v2)
|
||||||
|
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use base64::engine::general_purpose::STANDARD;
|
use base64::engine::general_purpose::STANDARD;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
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};
|
||||||
|
|
||||||
|
const RATE_LIMIT_DELAY_MS: u64 = 200; // Delay between API calls to avoid 429
|
||||||
|
|
||||||
const TIDAL_TOKEN_URL: &str = "https://auth.tidal.com/v1/oauth2/token";
|
const TIDAL_TOKEN_URL: &str = "https://auth.tidal.com/v1/oauth2/token";
|
||||||
const TIDAL_API_BASE: &str = "https://openapi.tidal.com/v2";
|
const TIDAL_API_BASE: &str = "https://openapi.tidal.com/v2";
|
||||||
|
|
||||||
@@ -39,16 +43,23 @@ pub async fn fetch_playlist(playlist_id: &str) -> Result<ImportPlaylist, Playlis
|
|||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let meta_url = format!("{}/playlists/{}", TIDAL_API_BASE, playlist_id);
|
let meta_url = format!("{}/playlists/{}", TIDAL_API_BASE, playlist_id);
|
||||||
let meta: Value = client
|
let resp = client
|
||||||
.get(&meta_url)
|
.get(&meta_url)
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.query(&[("countryCode", &country_code)])
|
.query(&[("countryCode", &country_code)])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
|
.map_err(|e| PlaylistImportError::Http(e.to_string()))?;
|
||||||
.json()
|
|
||||||
.await
|
let status = resp.status();
|
||||||
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
|
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
|
let name = meta
|
||||||
.get("data")
|
.get("data")
|
||||||
@@ -88,19 +99,29 @@ async fn fetch_track_ids(
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
let url = format!("{}{}", TIDAL_API_BASE, next_path);
|
let url = format!("{}{}", TIDAL_API_BASE, next_path);
|
||||||
|
sleep(Duration::from_millis(RATE_LIMIT_DELAY_MS)).await;
|
||||||
|
|
||||||
let mut request = client
|
let mut request = client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.header("Authorization", format!("Bearer {}", token));
|
.header("Authorization", format!("Bearer {}", token));
|
||||||
if !next_path.contains("countryCode=") {
|
if !next_path.contains("countryCode=") {
|
||||||
request = request.query(&[("countryCode", country_code)]);
|
request = request.query(&[("countryCode", country_code)]);
|
||||||
}
|
}
|
||||||
let response: Value = request
|
|
||||||
|
let resp = request
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
|
.map_err(|e| PlaylistImportError::Http(e.to_string()))?;
|
||||||
.json()
|
|
||||||
.await
|
let status = resp.status();
|
||||||
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
|
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()) {
|
if let Some(data) = response.get("data").and_then(|v| v.as_array()) {
|
||||||
for item in data {
|
for item in data {
|
||||||
@@ -135,14 +156,16 @@ async fn fetch_tracks_by_ids(
|
|||||||
) -> Result<Vec<ImportTrack>, PlaylistImportError> {
|
) -> Result<Vec<ImportTrack>, PlaylistImportError> {
|
||||||
let mut tracks = Vec::new();
|
let mut tracks = Vec::new();
|
||||||
let mut chunk_start = 0usize;
|
let mut chunk_start = 0usize;
|
||||||
let chunk_size = 50usize;
|
let chunk_size = 20usize; // Tidal API limit
|
||||||
|
|
||||||
while chunk_start < track_ids.len() {
|
while chunk_start < track_ids.len() {
|
||||||
let end = (chunk_start + chunk_size).min(track_ids.len());
|
let end = (chunk_start + chunk_size).min(track_ids.len());
|
||||||
let chunk = &track_ids[chunk_start..end];
|
let chunk = &track_ids[chunk_start..end];
|
||||||
|
|
||||||
let url = format!("{}/tracks", TIDAL_API_BASE);
|
let url = format!("{}/tracks", TIDAL_API_BASE);
|
||||||
let response: Value = client
|
sleep(Duration::from_millis(RATE_LIMIT_DELAY_MS)).await;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.query(&[
|
.query(&[
|
||||||
@@ -152,10 +175,17 @@ async fn fetch_tracks_by_ids(
|
|||||||
])
|
])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
|
.map_err(|e| PlaylistImportError::Http(e.to_string()))?;
|
||||||
.json()
|
|
||||||
.await
|
let status = resp.status();
|
||||||
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
|
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
|
let included = response
|
||||||
.get("included")
|
.get("included")
|
||||||
@@ -306,21 +336,28 @@ async fn get_app_token() -> Result<String, PlaylistImportError> {
|
|||||||
|
|
||||||
let auth = STANDARD.encode(format!("{}:{}", client_id, client_secret));
|
let auth = STANDARD.encode(format!("{}:{}", client_id, client_secret));
|
||||||
|
|
||||||
let response: Value = reqwest::Client::new()
|
let resp = reqwest::Client::new()
|
||||||
.post(TIDAL_TOKEN_URL)
|
.post(TIDAL_TOKEN_URL)
|
||||||
.header("Authorization", format!("Basic {}", auth))
|
.header("Authorization", format!("Basic {}", auth))
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
.body("grant_type=client_credentials")
|
.body("grant_type=client_credentials")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| PlaylistImportError::Http(e.to_string()))?
|
.map_err(|e| PlaylistImportError::Http(e.to_string()))?;
|
||||||
.json()
|
|
||||||
.await
|
let status = resp.status();
|
||||||
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
|
let body = resp.text().await.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(PlaylistImportError::Http(format!("Tidal auth failed: {} - {}", status, body)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Value = serde_json::from_str(&body)
|
||||||
|
.map_err(|e| PlaylistImportError::Parse(format!("Invalid JSON: {} - body: {}", e, &body[..body.len().min(200)])))?;
|
||||||
|
|
||||||
response
|
response
|
||||||
.get("access_token")
|
.get("access_token")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(|v| v.to_string())
|
.map(|v| v.to_string())
|
||||||
.ok_or_else(|| PlaylistImportError::Parse("Tidal token missing access_token".to_string()))
|
.ok_or_else(|| PlaylistImportError::Parse(format!("Tidal token missing access_token. Response: {}", body)))
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user