Merge feature/api-keys-refactor: Remove embedded API keys and add Discogs artwork selector
This major refactor achieves Flathub compliance by removing all embedded API credentials from the binary. All API requests now go through a Cloudflare Workers proxy for secure credential management. New Features: - Discogs artwork selection UI with multi-release support - Catalog number support for audiophile libraries - Carousel navigation for artwork options - Release metadata display (title, year) Technical Changes: - Cloudflare Workers proxy for Last.fm, Spotify, Tidal, Discogs - Database migration adds catalog_number column - URI scheme registration (qbz://) for OAuth flows - Rate limiting and User-Agent validation server-side See CHANGELOG-api-keys-refactor.md for complete details. See POST-MERGE-CLEANUP.md for removing legacy API Keys UI.
This commit is contained in:
commit
9516a1c1fa
18 files changed
+2264
-325
No files matched your search
@@ -50,13 +50,8 @@ pub async fn lastfm_get_auth_url(state: State<'_, AppState>) -> Result<(String,
|
||||
log::info!("Command: lastfm_get_auth_url");
|
||||
let client = state.lastfm.lock().await;
|
||||
|
||||
if !client.has_credentials() {
|
||||
return Err("Last.fm API credentials not configured. Please set API key and secret in settings.".to_string());
|
||||
}
|
||||
|
||||
let token = client.get_token().await?;
|
||||
let url = client.get_auth_url(&token);
|
||||
Ok((token, url))
|
||||
// Proxy handles credentials, always available
|
||||
client.get_token().await
|
||||
}
|
||||
|
||||
/// Complete Last.fm authentication with token
|
||||
|
||||
+244
-55
@@ -1,6 +1,6 @@
|
||||
//! Discogs API client for fetching album artwork
|
||||
//!
|
||||
//! Uses the Discogs database API to search for releases and download cover images.
|
||||
//! Uses Cloudflare Workers proxy to search the Discogs database and download cover images.
|
||||
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
@@ -8,11 +8,12 @@ use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
// Cloudflare Workers proxy URL - handles credentials
|
||||
const DISCOGS_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/discogs";
|
||||
|
||||
/// Discogs API client
|
||||
pub struct DiscogsClient {
|
||||
client: Client,
|
||||
consumer_key: Option<String>,
|
||||
consumer_secret: Option<String>,
|
||||
}
|
||||
|
||||
/// Search result from Discogs API
|
||||
@@ -31,46 +32,66 @@ pub struct SearchResult {
|
||||
pub result_type: String,
|
||||
}
|
||||
|
||||
// Compile-time embedded credentials (from build environment)
|
||||
const EMBEDDED_CONSUMER_KEY: Option<&str> = option_env!("DISCOGS_API_CLIENT_KEY");
|
||||
const EMBEDDED_CONSUMER_SECRET: Option<&str> = option_env!("DISCOGS_API_CLIENT_SECRET");
|
||||
/// Image option for artwork selection
|
||||
#[derive(Debug, Deserialize, serde::Serialize, Clone)]
|
||||
pub struct DiscogsImageOption {
|
||||
pub url: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
#[serde(rename = "type")]
|
||||
pub image_type: String,
|
||||
pub release_title: Option<String>,
|
||||
pub release_year: Option<u32>,
|
||||
}
|
||||
|
||||
/// Release details from Discogs API
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReleaseDetails {
|
||||
id: u64,
|
||||
title: String,
|
||||
year: Option<u32>,
|
||||
images: Option<Vec<ReleaseImage>>,
|
||||
}
|
||||
|
||||
/// Image from release details
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReleaseImage {
|
||||
uri: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
#[serde(rename = "type")]
|
||||
image_type: String,
|
||||
}
|
||||
|
||||
impl DiscogsClient {
|
||||
/// Create a new Discogs client with optional credentials
|
||||
/// Priority: user-provided > embedded > runtime env vars
|
||||
/// Create a new Discogs client (proxy handles credentials)
|
||||
pub fn new() -> Self {
|
||||
Self::with_user_credentials(None, None)
|
||||
}
|
||||
|
||||
/// Create a new Discogs client with user-provided credentials (override)
|
||||
/// Create a new Discogs client - compatibility method (proxy handles credentials)
|
||||
pub fn with_user_credentials(
|
||||
user_key: Option<String>,
|
||||
user_secret: Option<String>,
|
||||
_user_key: Option<String>,
|
||||
_user_secret: Option<String>,
|
||||
) -> Self {
|
||||
// Priority: user-provided > embedded > runtime env vars
|
||||
let consumer_key = user_key
|
||||
.or_else(|| EMBEDDED_CONSUMER_KEY.map(String::from))
|
||||
.or_else(|| std::env::var("DISCOGS_API_CLIENT_KEY").ok());
|
||||
let consumer_secret = user_secret
|
||||
.or_else(|| EMBEDDED_CONSUMER_SECRET.map(String::from))
|
||||
.or_else(|| std::env::var("DISCOGS_API_CLIENT_SECRET").ok());
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::USER_AGENT,
|
||||
reqwest::header::HeaderValue::from_static("QBZ/1.0.0"),
|
||||
);
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.user_agent("QBZ/0.1.0 +https://github.com/vicrodh/qbz")
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
Self {
|
||||
client,
|
||||
consumer_key,
|
||||
consumer_secret,
|
||||
}
|
||||
Self { client }
|
||||
}
|
||||
|
||||
/// Check if credentials are configured
|
||||
/// Check if credentials are configured (always true - proxy handles credentials)
|
||||
pub fn has_credentials(&self) -> bool {
|
||||
self.consumer_key.is_some() && self.consumer_secret.is_some()
|
||||
true
|
||||
}
|
||||
|
||||
/// Search for album artwork and download if found
|
||||
@@ -81,11 +102,6 @@ impl DiscogsClient {
|
||||
album: &str,
|
||||
cache_dir: &Path,
|
||||
) -> Option<String> {
|
||||
if !self.has_credentials() {
|
||||
log::debug!("Discogs credentials not configured, skipping artwork fetch");
|
||||
return None;
|
||||
}
|
||||
|
||||
// Search for the release
|
||||
let cover_url = self.search_release(artist, album).await?;
|
||||
|
||||
@@ -109,16 +125,12 @@ impl DiscogsClient {
|
||||
|
||||
/// Search for a release and return the cover image URL
|
||||
async fn search_release(&self, artist: &str, album: &str) -> Option<String> {
|
||||
let key = self.consumer_key.as_ref()?;
|
||||
let secret = self.consumer_secret.as_ref()?;
|
||||
|
||||
// Build search query
|
||||
let query = format!("{} {}", artist, album);
|
||||
let url = format!(
|
||||
"https://api.discogs.com/database/search?q={}&type=release&key={}&secret={}",
|
||||
urlencoding::encode(&query),
|
||||
key,
|
||||
secret
|
||||
"{}/search?q={}&type=release",
|
||||
DISCOGS_PROXY_URL,
|
||||
urlencoding::encode(&query)
|
||||
);
|
||||
|
||||
log::debug!("Searching Discogs for: {} - {}", artist, album);
|
||||
@@ -154,20 +166,195 @@ impl DiscogsClient {
|
||||
None
|
||||
}
|
||||
|
||||
/// Search for artists and return search results
|
||||
pub async fn search_artist(&self, query: &str) -> Result<SearchResponse, String> {
|
||||
if !self.has_credentials() {
|
||||
return Err("Discogs credentials not configured".to_string());
|
||||
/// Get detailed release information including all images
|
||||
async fn get_release_details(&self, release_id: u64) -> Result<ReleaseDetails, String> {
|
||||
let url = format!("{}/release/{}", DISCOGS_PROXY_URL, release_id);
|
||||
|
||||
log::debug!("Fetching Discogs release details for ID: {}", release_id);
|
||||
|
||||
let response = self.client.get(&url).send().await
|
||||
.map_err(|e| format!("Failed to fetch release details: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Failed to fetch release details: {}", response.status()));
|
||||
}
|
||||
|
||||
let key = self.consumer_key.as_ref().unwrap();
|
||||
let secret = self.consumer_secret.as_ref().unwrap();
|
||||
let details: ReleaseDetails = response.json().await
|
||||
.map_err(|e| format!("Failed to parse release details: {}", e))?;
|
||||
|
||||
Ok(details)
|
||||
}
|
||||
|
||||
/// Search for album artwork options
|
||||
/// Returns up to 10 image options, with detailed images from top 2 releases interleaved
|
||||
/// If catalog_number is provided, searches by that first, then falls back to artist + album
|
||||
pub async fn search_artwork_options(
|
||||
&self,
|
||||
artist: &str,
|
||||
album: &str,
|
||||
catalog_number: Option<&str>,
|
||||
) -> Result<Vec<DiscogsImageOption>, String> {
|
||||
// Build search query - prefer catalog number if available
|
||||
let query = if let Some(catno) = catalog_number.filter(|s| !s.trim().is_empty()) {
|
||||
catno.to_string()
|
||||
} else {
|
||||
format!("{} {}", artist, album)
|
||||
};
|
||||
let url = format!(
|
||||
"https://api.discogs.com/database/search?q={}&type=artist&key={}&secret={}",
|
||||
urlencoding::encode(query),
|
||||
key,
|
||||
secret
|
||||
"{}/search?q={}&type=release",
|
||||
DISCOGS_PROXY_URL,
|
||||
urlencoding::encode(&query)
|
||||
);
|
||||
|
||||
log::debug!(
|
||||
"Searching Discogs artwork options for: {} - {} (catalog: {:?})",
|
||||
artist,
|
||||
album,
|
||||
catalog_number
|
||||
);
|
||||
|
||||
let response = self.client.get(&url).send().await
|
||||
.map_err(|e| format!("Failed to search Discogs: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Discogs search failed with status: {}", response.status()));
|
||||
}
|
||||
|
||||
let search: SearchResponse = response.json().await
|
||||
.map_err(|e| format!("Failed to parse Discogs response: {}", e))?;
|
||||
|
||||
// Get IDs of top 2 relevant releases
|
||||
let mut release_ids: Vec<u64> = Vec::new();
|
||||
let mut other_results: Vec<&SearchResult> = Vec::new();
|
||||
|
||||
for result in search.results.iter().take(20) {
|
||||
if result.result_type == "release" || result.result_type == "master" {
|
||||
if release_ids.len() < 2 {
|
||||
release_ids.push(result.id);
|
||||
} else {
|
||||
other_results.push(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if release_ids.is_empty() {
|
||||
return Err("No releases found on Discogs".to_string());
|
||||
}
|
||||
|
||||
let mut all_images = Vec::new();
|
||||
let mut seen_urls = std::collections::HashSet::new();
|
||||
|
||||
// Fetch detailed images from top 2 releases
|
||||
for (idx, release_id) in release_ids.iter().enumerate() {
|
||||
match self.get_release_details(*release_id).await {
|
||||
Ok(details) => {
|
||||
if let Some(images) = details.images {
|
||||
let mut count = 0;
|
||||
for img in images {
|
||||
if !img.uri.is_empty()
|
||||
&& !img.uri.contains("spacer.gif")
|
||||
&& seen_urls.insert(img.uri.clone())
|
||||
&& count < 4
|
||||
{
|
||||
all_images.push(DiscogsImageOption {
|
||||
url: img.uri,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
image_type: img.image_type,
|
||||
release_title: Some(details.title.clone()),
|
||||
release_year: details.year,
|
||||
});
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
log::debug!("Added {} images from release #{} ({})", count, idx + 1, details.title);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to fetch details for release {}: {}", release_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add up to 2 more images from other search results
|
||||
for result in other_results.iter().take(10) {
|
||||
if all_images.len() >= 10 {
|
||||
break;
|
||||
}
|
||||
|
||||
// Prefer cover image
|
||||
let image_url = if let Some(cover) = &result.cover_image {
|
||||
if !cover.is_empty() && !cover.contains("spacer.gif") {
|
||||
Some((cover.clone(), 600, 600, "primary".to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let image_url = image_url.or_else(|| {
|
||||
result.thumb.as_ref().and_then(|thumb| {
|
||||
if !thumb.is_empty() && !thumb.contains("spacer.gif") {
|
||||
Some((thumb.clone(), 150, 150, "secondary".to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if let Some((url, width, height, img_type)) = image_url {
|
||||
if seen_urls.insert(url.clone()) {
|
||||
all_images.push(DiscogsImageOption {
|
||||
url,
|
||||
width,
|
||||
height,
|
||||
image_type: img_type,
|
||||
release_title: Some(result.title.clone()),
|
||||
release_year: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if all_images.is_empty() {
|
||||
return Err("No artwork found on Discogs".to_string());
|
||||
}
|
||||
|
||||
// Return up to 10 unique images
|
||||
all_images.truncate(10);
|
||||
log::info!("Returning {} artwork options from Discogs", all_images.len());
|
||||
Ok(all_images)
|
||||
}
|
||||
|
||||
/// Download image from URL and return local path
|
||||
pub async fn download_artwork_from_url(
|
||||
&self,
|
||||
image_url: &str,
|
||||
cache_dir: &Path,
|
||||
artist: &str,
|
||||
album: &str,
|
||||
) -> Result<String, String> {
|
||||
// Generate cache filename
|
||||
let filename = format!(
|
||||
"discogs_{:x}.jpg",
|
||||
Self::simple_hash(&format!("{}_{}", artist, album))
|
||||
);
|
||||
let cache_path = cache_dir.join(&filename);
|
||||
|
||||
// Download the image
|
||||
self.download_image(image_url, &cache_path).await
|
||||
.ok_or_else(|| "Failed to download image".to_string())?;
|
||||
|
||||
Ok(cache_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Search for artists and return search results
|
||||
pub async fn search_artist(&self, query: &str) -> Result<SearchResponse, String> {
|
||||
let url = format!(
|
||||
"{}/search?q={}&type=artist",
|
||||
DISCOGS_PROXY_URL,
|
||||
urlencoding::encode(query)
|
||||
);
|
||||
|
||||
log::debug!("Searching Discogs for artist: {}", query);
|
||||
@@ -186,17 +373,19 @@ impl DiscogsClient {
|
||||
}
|
||||
|
||||
/// Download an image to the cache directory
|
||||
async fn download_image(&self, url: &str, path: &Path) -> Option<()> {
|
||||
log::debug!("Downloading Discogs artwork: {}", url);
|
||||
async fn download_image(&self, image_url: &str, path: &Path) -> Option<()> {
|
||||
log::debug!("Downloading Discogs artwork: {}", image_url);
|
||||
|
||||
let key = self.consumer_key.as_ref()?;
|
||||
let secret = self.consumer_secret.as_ref()?;
|
||||
// Use proxy to download image with authentication
|
||||
let proxy_url = format!(
|
||||
"{}/image?url={}",
|
||||
DISCOGS_PROXY_URL,
|
||||
urlencoding::encode(image_url)
|
||||
);
|
||||
|
||||
// Discogs requires auth even for image downloads
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.header("Authorization", format!("Discogs key={}, secret={}", key, secret))
|
||||
.get(&proxy_url)
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
+63
-160
@@ -1,11 +1,10 @@
|
||||
//! Last.fm integration module
|
||||
//!
|
||||
//! Handles Last.fm authentication and scrobbling
|
||||
//! Handles Last.fm authentication and scrobbling via Cloudflare Workers proxy
|
||||
|
||||
use md5::{Digest, Md5};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use serde_json::json;
|
||||
|
||||
/// Deserialize integer (0/1) as boolean - Last.fm API returns subscriber as number
|
||||
fn deserialize_int_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
|
||||
@@ -20,31 +19,8 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
const LASTFM_API_URL: &str = "https://ws.audioscrobbler.com/2.0/";
|
||||
|
||||
// Last.fm API credentials
|
||||
// Checked in order:
|
||||
// 1. Compile-time environment variables (for release builds)
|
||||
// 2. Runtime environment variables (for development with .env)
|
||||
// Note: Supports both LAST_FM_* and LASTFM_* naming conventions
|
||||
const DEFAULT_API_KEY: Option<&str> = option_env!("LAST_FM_API_KEY");
|
||||
const DEFAULT_API_SECRET: Option<&str> = option_env!("LAST_FM_API_SHARED_SECRET");
|
||||
|
||||
/// Get API key from compile-time or runtime environment
|
||||
fn get_api_key() -> Option<String> {
|
||||
DEFAULT_API_KEY
|
||||
.map(String::from)
|
||||
.or_else(|| std::env::var("LAST_FM_API_KEY").ok())
|
||||
.or_else(|| std::env::var("LASTFM_API_KEY").ok())
|
||||
}
|
||||
|
||||
/// Get API secret from compile-time or runtime environment
|
||||
fn get_api_secret() -> Option<String> {
|
||||
DEFAULT_API_SECRET
|
||||
.map(String::from)
|
||||
.or_else(|| std::env::var("LAST_FM_API_SHARED_SECRET").ok())
|
||||
.or_else(|| std::env::var("LASTFM_API_SECRET").ok())
|
||||
}
|
||||
// Cloudflare Workers proxy URL - handles API credentials and signature generation
|
||||
const LASTFM_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/lastfm";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LastFmSession {
|
||||
@@ -62,6 +38,8 @@ struct AuthGetSessionResponse {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthGetTokenResponse {
|
||||
token: String,
|
||||
#[serde(rename = "authUrl")]
|
||||
auth_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -72,35 +50,36 @@ enum LastFmResponse<T> {
|
||||
}
|
||||
|
||||
/// Last.fm API client
|
||||
/// Uses Cloudflare Workers proxy to handle API credentials and signature generation
|
||||
pub struct LastFmClient {
|
||||
client: Client,
|
||||
api_key: String,
|
||||
api_secret: String,
|
||||
session_key: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for LastFmClient {
|
||||
fn default() -> Self {
|
||||
Self::new(
|
||||
get_api_key().unwrap_or_default(),
|
||||
get_api_secret().unwrap_or_default(),
|
||||
)
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl LastFmClient {
|
||||
/// Check if embedded (build-time or runtime) credentials are available
|
||||
/// Check if embedded credentials are available (always true - proxy handles them)
|
||||
pub fn has_embedded_credentials() -> bool {
|
||||
get_api_key().is_some() && get_api_secret().is_some()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl LastFmClient {
|
||||
pub fn new(api_key: String, api_secret: String) -> Self {
|
||||
pub fn new() -> Self {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::USER_AGENT,
|
||||
reqwest::header::HeaderValue::from_static("QBZ/1.0.0"),
|
||||
);
|
||||
|
||||
Self {
|
||||
client: Client::new(),
|
||||
api_key,
|
||||
api_secret,
|
||||
client: Client::builder()
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
session_key: None,
|
||||
}
|
||||
}
|
||||
@@ -110,61 +89,28 @@ impl LastFmClient {
|
||||
}
|
||||
|
||||
pub fn is_authenticated(&self) -> bool {
|
||||
self.session_key.is_some() && !self.api_key.is_empty()
|
||||
self.session_key.is_some()
|
||||
}
|
||||
|
||||
/// Compatibility: proxy handles credentials, this is a no-op
|
||||
pub fn has_credentials(&self) -> bool {
|
||||
!self.api_key.is_empty() && !self.api_secret.is_empty()
|
||||
true
|
||||
}
|
||||
|
||||
pub fn set_credentials(&mut self, api_key: String, api_secret: String) {
|
||||
self.api_key = api_key;
|
||||
self.api_secret = api_secret;
|
||||
/// Compatibility: proxy handles credentials, this is a no-op
|
||||
pub fn set_credentials(&mut self, _api_key: String, _api_secret: String) {
|
||||
// No-op: proxy handles credentials
|
||||
}
|
||||
|
||||
/// Generate API signature for authenticated requests
|
||||
fn generate_signature(&self, params: &BTreeMap<&str, &str>) -> String {
|
||||
let mut sig_string = String::new();
|
||||
for (key, value) in params {
|
||||
sig_string.push_str(key);
|
||||
sig_string.push_str(value);
|
||||
}
|
||||
sig_string.push_str(&self.api_secret);
|
||||
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(sig_string.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
/// Get authorization URL for user to visit
|
||||
pub fn get_auth_url(&self, token: &str) -> String {
|
||||
format!(
|
||||
"https://www.last.fm/api/auth/?api_key={}&token={}",
|
||||
self.api_key, token
|
||||
)
|
||||
}
|
||||
|
||||
/// Get a request token for authentication
|
||||
pub async fn get_token(&self) -> Result<String, String> {
|
||||
if !self.has_credentials() {
|
||||
return Err("Last.fm API credentials not configured".to_string());
|
||||
}
|
||||
|
||||
let mut params = BTreeMap::new();
|
||||
params.insert("method", "auth.getToken");
|
||||
params.insert("api_key", &self.api_key);
|
||||
|
||||
let sig = self.generate_signature(¶ms);
|
||||
/// Get a request token and authorization URL for authentication
|
||||
/// Returns: (token, auth_url)
|
||||
pub async fn get_token(&self) -> Result<(String, String), String> {
|
||||
let url = format!("{}/auth.getToken", LASTFM_PROXY_URL);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(LASTFM_API_URL)
|
||||
.query(&[
|
||||
("method", "auth.getToken"),
|
||||
("api_key", &self.api_key),
|
||||
("api_sig", &sig),
|
||||
("format", "json"),
|
||||
])
|
||||
.post(&url)
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get token: {}", e))?;
|
||||
@@ -175,37 +121,26 @@ impl LastFmClient {
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
match data {
|
||||
LastFmResponse::Success(r) => Ok(r.token),
|
||||
LastFmResponse::Success(r) => {
|
||||
let auth_url = r.auth_url.unwrap_or_else(|| {
|
||||
format!("https://www.last.fm/api/auth/?token={}", r.token)
|
||||
});
|
||||
Ok((r.token, auth_url))
|
||||
}
|
||||
LastFmResponse::Error { message, .. } => Err(message),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get session key after user has authorized
|
||||
pub async fn get_session(&mut self, token: &str) -> Result<LastFmSession, String> {
|
||||
if !self.has_credentials() {
|
||||
return Err("Last.fm API credentials not configured".to_string());
|
||||
}
|
||||
|
||||
log::info!("Getting Last.fm session with token: {}...", &token[..token.len().min(8)]);
|
||||
|
||||
let mut params = BTreeMap::new();
|
||||
params.insert("method", "auth.getSession");
|
||||
params.insert("api_key", &self.api_key);
|
||||
params.insert("token", token);
|
||||
|
||||
let sig = self.generate_signature(¶ms);
|
||||
log::debug!("Generated signature for auth.getSession");
|
||||
let url = format!("{}/auth.getSession", LASTFM_PROXY_URL);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(LASTFM_API_URL)
|
||||
.query(&[
|
||||
("method", "auth.getSession"),
|
||||
("api_key", &self.api_key),
|
||||
("token", token),
|
||||
("api_sig", &sig),
|
||||
("format", "json"),
|
||||
])
|
||||
.post(&url)
|
||||
.json(&json!({ "token": token }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get session: {}", e))?;
|
||||
@@ -246,40 +181,23 @@ impl LastFmClient {
|
||||
.as_ref()
|
||||
.ok_or("Not authenticated with Last.fm")?;
|
||||
|
||||
let timestamp_str = timestamp.to_string();
|
||||
let api_key = &self.api_key;
|
||||
let url = format!("{}/track.scrobble", LASTFM_PROXY_URL);
|
||||
|
||||
let mut body = json!({
|
||||
"sk": session_key,
|
||||
"artist": artist,
|
||||
"track": track,
|
||||
"timestamp": timestamp.to_string(),
|
||||
});
|
||||
|
||||
let mut params = BTreeMap::new();
|
||||
params.insert("method", "track.scrobble");
|
||||
params.insert("api_key", api_key.as_str());
|
||||
params.insert("sk", session_key.as_str());
|
||||
params.insert("artist", artist);
|
||||
params.insert("track", track);
|
||||
params.insert("timestamp", ×tamp_str);
|
||||
if let Some(album_name) = album {
|
||||
params.insert("album", album_name);
|
||||
}
|
||||
|
||||
let sig = self.generate_signature(¶ms);
|
||||
|
||||
let mut form_params = vec![
|
||||
("method", "track.scrobble"),
|
||||
("api_key", api_key.as_str()),
|
||||
("sk", session_key.as_str()),
|
||||
("artist", artist),
|
||||
("track", track),
|
||||
("timestamp", ×tamp_str),
|
||||
("api_sig", &sig),
|
||||
("format", "json"),
|
||||
];
|
||||
if let Some(album_name) = album {
|
||||
form_params.push(("album", album_name));
|
||||
body["album"] = json!(album_name);
|
||||
}
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(LASTFM_API_URL)
|
||||
.form(&form_params)
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to scrobble: {}", e))?;
|
||||
@@ -305,37 +223,22 @@ impl LastFmClient {
|
||||
.as_ref()
|
||||
.ok_or("Not authenticated with Last.fm")?;
|
||||
|
||||
let api_key = &self.api_key;
|
||||
let url = format!("{}/track.updateNowPlaying", LASTFM_PROXY_URL);
|
||||
|
||||
let mut body = json!({
|
||||
"sk": session_key,
|
||||
"artist": artist,
|
||||
"track": track,
|
||||
});
|
||||
|
||||
let mut params = BTreeMap::new();
|
||||
params.insert("method", "track.updateNowPlaying");
|
||||
params.insert("api_key", api_key.as_str());
|
||||
params.insert("sk", session_key.as_str());
|
||||
params.insert("artist", artist);
|
||||
params.insert("track", track);
|
||||
if let Some(album_name) = album {
|
||||
params.insert("album", album_name);
|
||||
}
|
||||
|
||||
let sig = self.generate_signature(¶ms);
|
||||
|
||||
let mut form_params = vec![
|
||||
("method", "track.updateNowPlaying"),
|
||||
("api_key", api_key.as_str()),
|
||||
("sk", session_key.as_str()),
|
||||
("artist", artist),
|
||||
("track", track),
|
||||
("api_sig", &sig),
|
||||
("format", "json"),
|
||||
];
|
||||
if let Some(album_name) = album {
|
||||
form_params.push(("album", album_name));
|
||||
body["album"] = json!(album_name);
|
||||
}
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(LASTFM_API_URL)
|
||||
.form(&form_params)
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to update now playing: {}", e))?;
|
||||
|
||||
@@ -443,6 +443,8 @@ pub fn run() {
|
||||
// Discogs artwork commands
|
||||
library::commands::discogs_has_credentials,
|
||||
library::commands::discogs_search_artist,
|
||||
library::commands::discogs_search_artwork,
|
||||
library::commands::discogs_download_artwork,
|
||||
library::commands::library_fetch_missing_artwork,
|
||||
library::commands::library_fetch_album_artwork,
|
||||
library::commands::library_set_album_artwork,
|
||||
|
||||
@@ -1738,3 +1738,40 @@ pub async fn playlist_get_offline_available(
|
||||
|
||||
Ok(playlists.iter().map(|p| p.qobuz_playlist_id).collect())
|
||||
}
|
||||
|
||||
// === Discogs Artwork ===
|
||||
|
||||
/// Search Discogs for artwork options
|
||||
#[tauri::command]
|
||||
pub async fn discogs_search_artwork(
|
||||
artist: String,
|
||||
album: String,
|
||||
catalog_number: Option<String>,
|
||||
) -> Result<Vec<crate::discogs::DiscogsImageOption>, String> {
|
||||
log::info!(
|
||||
"Command: discogs_search_artwork {} - {} (catalog: {:?})",
|
||||
artist,
|
||||
album,
|
||||
catalog_number
|
||||
);
|
||||
|
||||
let client = DiscogsClient::new();
|
||||
client
|
||||
.search_artwork_options(&artist, &album, catalog_number.as_deref())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Download and save Discogs artwork
|
||||
#[tauri::command]
|
||||
pub async fn discogs_download_artwork(
|
||||
image_url: String,
|
||||
artist: String,
|
||||
album: String,
|
||||
) -> Result<String, String> {
|
||||
log::info!("Command: discogs_download_artwork from {}", image_url);
|
||||
|
||||
let cache_dir = get_artwork_cache_dir();
|
||||
let client = DiscogsClient::new();
|
||||
|
||||
client.download_artwork_from_url(&image_url, &cache_dir, &artist, &album).await
|
||||
}
|
||||
@@ -257,6 +257,7 @@ pub fn cue_to_tracks(
|
||||
disc_number: inferred_disc,
|
||||
year: None,
|
||||
genre: None,
|
||||
catalog_number: None,
|
||||
duration_secs: duration,
|
||||
format: format.clone(),
|
||||
bit_depth: properties.bit_depth,
|
||||
|
||||
@@ -327,6 +327,24 @@ impl LibraryDatabase {
|
||||
).map_err(|e| LibraryError::Database(format!("Migration failed: {}", e)))?;
|
||||
}
|
||||
|
||||
// Migration: Add catalog_number column to local_tracks
|
||||
let has_catalog_number: bool = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('local_tracks') WHERE name = 'catalog_number'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.map(|count| count > 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_catalog_number {
|
||||
log::info!("Running migration: adding catalog_number to local_tracks");
|
||||
self.conn
|
||||
.execute_batch("ALTER TABLE local_tracks ADD COLUMN catalog_number TEXT;")
|
||||
.map_err(|e| LibraryError::Database(format!("Migration failed: {}", e)))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -531,11 +549,11 @@ impl LibraryDatabase {
|
||||
.execute(
|
||||
r#"INSERT OR REPLACE INTO local_tracks
|
||||
(file_path, title, artist, album, album_artist, track_number,
|
||||
disc_number, year, genre, duration_secs, format, bit_depth,
|
||||
disc_number, year, genre, catalog_number, duration_secs, format, bit_depth,
|
||||
sample_rate, channels, file_size_bytes, cue_file_path,
|
||||
cue_start_secs, cue_end_secs, artwork_path, last_modified, indexed_at,
|
||||
album_group_key, album_group_title)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
|
||||
params![
|
||||
track.file_path,
|
||||
track.title,
|
||||
@@ -546,6 +564,7 @@ impl LibraryDatabase {
|
||||
track.disc_number,
|
||||
track.year,
|
||||
track.genre,
|
||||
track.catalog_number,
|
||||
track.duration_secs,
|
||||
track.format.to_string(),
|
||||
track.bit_depth,
|
||||
@@ -645,6 +664,7 @@ impl LibraryDatabase {
|
||||
ELSE MIN(artist)
|
||||
END as artist,
|
||||
MIN(year) as year,
|
||||
MIN(catalog_number) as catalog_number,
|
||||
MAX(CASE WHEN artwork_path IS NOT NULL THEN artwork_path END) as artwork,
|
||||
COUNT(*) as track_count,
|
||||
SUM(duration_secs) as total_duration,
|
||||
@@ -658,6 +678,7 @@ impl LibraryDatabase {
|
||||
COALESCE(album_group_title, album) as title,
|
||||
COALESCE(album_artist, artist) as artist,
|
||||
year,
|
||||
catalog_number,
|
||||
artwork_path,
|
||||
duration_secs,
|
||||
format,
|
||||
@@ -682,6 +703,7 @@ impl LibraryDatabase {
|
||||
ELSE MIN(artist)
|
||||
END as artist,
|
||||
MIN(year) as year,
|
||||
MIN(catalog_number) as catalog_number,
|
||||
MAX(CASE WHEN artwork_path IS NOT NULL THEN artwork_path END) as artwork,
|
||||
COUNT(*) as track_count,
|
||||
SUM(duration_secs) as total_duration,
|
||||
@@ -695,6 +717,7 @@ impl LibraryDatabase {
|
||||
COALESCE(album_group_title, album) as title,
|
||||
COALESCE(album_artist, artist) as artist,
|
||||
year,
|
||||
catalog_number,
|
||||
artwork_path,
|
||||
duration_secs,
|
||||
format,
|
||||
@@ -723,25 +746,26 @@ impl LibraryDatabase {
|
||||
let group_key: String = row.get(0)?;
|
||||
let album: String = row.get(1)?;
|
||||
let artist: String = row.get(2)?;
|
||||
let artwork_path: Option<String> = row.get(4)?;
|
||||
|
||||
let artwork_path: Option<String> = row.get(5)?;
|
||||
|
||||
log::info!("Album {} by {}: artwork_path = {:?}", album, artist, artwork_path);
|
||||
|
||||
|
||||
Ok(LocalAlbum {
|
||||
id: group_key.clone(),
|
||||
title: album,
|
||||
artist,
|
||||
year: row.get(3)?,
|
||||
catalog_number: row.get(4)?,
|
||||
artwork_path,
|
||||
track_count: row.get(5)?,
|
||||
total_duration_secs: row.get(6)?,
|
||||
track_count: row.get(6)?,
|
||||
total_duration_secs: row.get(7)?,
|
||||
format: Self::parse_format(
|
||||
&row.get::<_, Option<String>>(7)?.unwrap_or_default(),
|
||||
&row.get::<_, Option<String>>(8)?.unwrap_or_default(),
|
||||
),
|
||||
bit_depth: row.get(8)?,
|
||||
sample_rate: row.get::<_, Option<u32>>(9)?.unwrap_or(44100),
|
||||
bit_depth: row.get(9)?,
|
||||
sample_rate: row.get::<_, Option<u32>>(10)?.unwrap_or(44100),
|
||||
directory_path: row
|
||||
.get::<_, Option<String>>(10)?
|
||||
.get::<_, Option<String>>(11)?
|
||||
.unwrap_or_else(|| group_key.clone()),
|
||||
})
|
||||
})
|
||||
@@ -991,6 +1015,7 @@ impl LibraryDatabase {
|
||||
disc_number: row.get(7)?,
|
||||
year: row.get(8)?,
|
||||
genre: row.get(9)?,
|
||||
catalog_number: row.get(26).ok().flatten(),
|
||||
duration_secs: row.get(10)?,
|
||||
format: Self::parse_format(&row.get::<_, String>(11)?),
|
||||
bit_depth: row.get(12)?,
|
||||
@@ -1594,6 +1619,7 @@ impl LibraryDatabase {
|
||||
disc_number: row.get(9)?,
|
||||
year: row.get(10)?,
|
||||
genre: row.get(11)?,
|
||||
catalog_number: None,
|
||||
duration_secs: row.get(12)?,
|
||||
format: Self::parse_format(&row.get::<_, String>(13)?),
|
||||
bit_depth: row.get(14)?,
|
||||
@@ -1642,6 +1668,7 @@ impl LibraryDatabase {
|
||||
disc_number: row.get(9)?,
|
||||
year: row.get(10)?,
|
||||
genre: row.get(11)?,
|
||||
catalog_number: None,
|
||||
duration_secs: row.get(12)?,
|
||||
format: Self::parse_format(&row.get::<_, String>(13)?),
|
||||
bit_depth: row.get(14)?,
|
||||
|
||||
@@ -325,6 +325,7 @@ impl MetadataExtractor {
|
||||
.or(inferred_disc),
|
||||
year: tag.year().map(|y| y as u32),
|
||||
genre: tag.genre().map(|s| s.to_string()),
|
||||
catalog_number: tag.get_string(&ItemKey::CatalogNumber).map(|s| s.to_string()),
|
||||
duration_secs,
|
||||
format,
|
||||
bit_depth,
|
||||
@@ -363,6 +364,7 @@ impl MetadataExtractor {
|
||||
disc_number: inferred_disc,
|
||||
year: None,
|
||||
genre: None,
|
||||
catalog_number: None,
|
||||
duration_secs,
|
||||
format,
|
||||
bit_depth,
|
||||
|
||||
@@ -51,6 +51,7 @@ pub struct LocalTrack {
|
||||
pub disc_number: Option<u32>,
|
||||
pub year: Option<u32>,
|
||||
pub genre: Option<String>,
|
||||
pub catalog_number: Option<String>,
|
||||
|
||||
// Audio properties
|
||||
pub duration_secs: u64,
|
||||
@@ -88,6 +89,7 @@ impl Default for LocalTrack {
|
||||
disc_number: None,
|
||||
year: None,
|
||||
genre: None,
|
||||
catalog_number: None,
|
||||
duration_secs: 0,
|
||||
format: AudioFormat::Unknown,
|
||||
bit_depth: None,
|
||||
@@ -120,6 +122,7 @@ pub struct LocalAlbum {
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub year: Option<u32>,
|
||||
pub catalog_number: Option<String>,
|
||||
pub artwork_path: Option<String>,
|
||||
pub track_count: u32,
|
||||
pub total_duration_secs: u64,
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
//! Spotify playlist import
|
||||
|
||||
use std::env;
|
||||
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::playlist_import::errors::PlaylistImportError;
|
||||
use crate::playlist_import::models::{ImportPlaylist, ImportProvider, ImportTrack};
|
||||
use crate::playlist_import::providers::ProviderCredentials;
|
||||
|
||||
const SPOTIFY_TOKEN_URL: &str = "https://accounts.spotify.com/api/token";
|
||||
// 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";
|
||||
|
||||
// Compile-time embedded credentials (from build environment)
|
||||
const EMBEDDED_CLIENT_ID: Option<&str> = option_env!("SPOTIFY_API_CLIENT_ID");
|
||||
const EMBEDDED_CLIENT_SECRET: Option<&str> = option_env!("SPOTIFY_API_CLIENT_SECRET");
|
||||
|
||||
pub fn parse_playlist_id(url: &str) -> Option<String> {
|
||||
if let Some(rest) = url.strip_prefix("spotify:playlist:") {
|
||||
if !rest.is_empty() {
|
||||
@@ -256,28 +249,22 @@ async fn fetch_playlist_from_embed(playlist_id: &str) -> Result<ImportPlaylist,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_app_token(user_creds: Option<ProviderCredentials>) -> Result<String, PlaylistImportError> {
|
||||
// Priority: user-provided > embedded > runtime env vars
|
||||
let client_id = user_creds
|
||||
.as_ref()
|
||||
.and_then(|c| c.client_id.clone())
|
||||
.or_else(|| EMBEDDED_CLIENT_ID.map(String::from))
|
||||
.or_else(|| env::var("SPOTIFY_API_CLIENT_ID").ok())
|
||||
.ok_or_else(|| PlaylistImportError::MissingCredentials("SPOTIFY_API_CLIENT_ID".to_string()))?;
|
||||
let client_secret = user_creds
|
||||
.as_ref()
|
||||
.and_then(|c| c.client_secret.clone())
|
||||
.or_else(|| EMBEDDED_CLIENT_SECRET.map(String::from))
|
||||
.or_else(|| env::var("SPOTIFY_API_CLIENT_SECRET").ok())
|
||||
.ok_or_else(|| PlaylistImportError::MissingCredentials("SPOTIFY_API_CLIENT_SECRET".to_string()))?;
|
||||
async fn get_app_token(_user_creds: Option<ProviderCredentials>) -> Result<String, PlaylistImportError> {
|
||||
// Proxy handles credentials - user_creds ignored (compatibility)
|
||||
let url = format!("{}/token", SPOTIFY_PROXY_URL);
|
||||
|
||||
let auth = STANDARD.encode(format!("{}:{}", client_id, client_secret));
|
||||
|
||||
let response: Value = reqwest::Client::new()
|
||||
.post(SPOTIFY_TOKEN_URL)
|
||||
.header("Authorization", format!("Basic {}", auth))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.body("grant_type=client_credentials")
|
||||
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(e.to_string()))?
|
||||
@@ -289,7 +276,7 @@ async fn get_app_token(user_creds: Option<ProviderCredentials>) -> Result<String
|
||||
.get("access_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|v| v.to_string())
|
||||
.ok_or_else(|| PlaylistImportError::Parse("Spotify token missing access_token".to_string()))
|
||||
.ok_or_else(|| PlaylistImportError::Parse("Spotify proxy missing access_token".to_string()))
|
||||
}
|
||||
|
||||
fn join_artists(value: Option<&Value>) -> String {
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
use std::env;
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use serde_json::Value;
|
||||
use tokio::time::sleep;
|
||||
|
||||
@@ -14,13 +12,10 @@ use crate::playlist_import::providers::ProviderCredentials;
|
||||
|
||||
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";
|
||||
// Cloudflare Workers proxy URL - handles credentials
|
||||
const TIDAL_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/tidal";
|
||||
const TIDAL_API_BASE: &str = "https://openapi.tidal.com/v2";
|
||||
|
||||
// Compile-time embedded credentials (from build environment)
|
||||
const EMBEDDED_CLIENT_ID: Option<&str> = option_env!("TIDAL_API_CLIENT_ID");
|
||||
const EMBEDDED_CLIENT_SECRET: Option<&str> = option_env!("TIDAL_API_CLIENT_SECRET");
|
||||
|
||||
pub fn parse_playlist_id(url: &str) -> Option<String> {
|
||||
if !url.contains("tidal.com") {
|
||||
return None;
|
||||
@@ -336,45 +331,32 @@ fn parse_duration_ms(value: &str) -> Option<u64> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_app_token(user_creds: Option<ProviderCredentials>) -> Result<String, PlaylistImportError> {
|
||||
// Priority: user-provided > embedded > runtime env vars
|
||||
let client_id = user_creds
|
||||
.as_ref()
|
||||
.and_then(|c| c.client_id.clone())
|
||||
.or_else(|| EMBEDDED_CLIENT_ID.map(String::from))
|
||||
.or_else(|| env::var("TIDAL_API_CLIENT_ID").ok())
|
||||
.ok_or_else(|| PlaylistImportError::MissingCredentials("TIDAL_API_CLIENT_ID".to_string()))?;
|
||||
let client_secret = user_creds
|
||||
.as_ref()
|
||||
.and_then(|c| c.client_secret.clone())
|
||||
.or_else(|| EMBEDDED_CLIENT_SECRET.map(String::from))
|
||||
.or_else(|| env::var("TIDAL_API_CLIENT_SECRET").ok())
|
||||
.ok_or_else(|| PlaylistImportError::MissingCredentials("TIDAL_API_CLIENT_SECRET".to_string()))?;
|
||||
async fn get_app_token(_user_creds: Option<ProviderCredentials>) -> Result<String, PlaylistImportError> {
|
||||
// Proxy handles credentials - user_creds ignored (compatibility)
|
||||
let url = format!("{}/token", TIDAL_PROXY_URL);
|
||||
|
||||
let auth = STANDARD.encode(format!("{}:{}", client_id, client_secret));
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.post(TIDAL_TOKEN_URL)
|
||||
.header("Authorization", format!("Basic {}", auth))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.body("grant_type=client_credentials")
|
||||
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(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 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)])))?;
|
||||
.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(format!("Tidal token missing access_token. Response: {}", body)))
|
||||
.ok_or_else(|| PlaylistImportError::Parse("Tidal proxy missing access_token".to_string()))
|
||||
}
|
||||
Reference in new issue
Block a user