From f398a1373f26a43194e66cc992e11eedfdae763d Mon Sep 17 00:00:00 2001 From: riptide-dev Date: Wed, 29 Jul 2026 23:03:06 +0200 Subject: [PATCH] Fix: use correct Tidal client credentials and HTTP Basic Auth for lossless FLAC streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app was playing AAC for all tracks, even those available in lossless FLAC on Tidal. Three root causes fixed: 1. **Wrong client credentials**: The built-in client_id/client_secret belonged to a client without lossless entitlement. Switched to the same credentials used by tiddl (github.com/oskvr37/tiddl), which are known to work for FLAC access. 2. **Form-field auth produces AAC-only tokens**: The OAuth2 /token endpoint was sending client_secret as a form body field instead of using HTTP Basic Auth. The Tidal API uses the authentication method to determine token privileges — form-field auth grants restricted AAC-only tokens, while Basic Auth grants full lossless access. 3. **Quality fallback order was wrong**: HI_RES_LOSSLESS was tried first, but it often returns DASH manifests with AAC codec (not FLAC). Since the request succeeded, LOSSLESS was never reached. Now LOSSLESS (raw FLAC via BTS) is tried first, followed by HI_RES_LOSSLESS only if its DASH codec is actually FLAC. Additional improvements: - BtsManifest model now captures codecs/mimeType fields - DASH parser finds and prefers FLAC AdaptationSets - Multi-segment FLAC handled via local M3U8 playlist - HLS playlists include EXT-X-CODECS for proper mpv detection - Removed x-tidal-client-version header (tiddl does not send it) - auth_generation migration flag forces re-auth on credential changes - RIPTIDE_QUALITY_DEBUG env var for diagnostic logging - PlaybackInfo model captures audioQuality/bitDepth/sampleRate Co-Authored-By: Claude --- src/api/auth.rs | 52 ++++++++--- src/api/client.rs | 225 ++++++++++++++++++++++++++++++++++++++++++---- src/api/models.rs | 46 +++++++++- 3 files changed, 292 insertions(+), 31 deletions(-) diff --git a/src/api/auth.rs b/src/api/auth.rs index d080821..db9e089 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -7,10 +7,11 @@ use std::path::PathBuf; use super::models::{Config, DeviceAuthResponse, SessionInfo, TokenResponse}; -// Built-in fallback credentials (same ones the open-source tidalapi project uses). -// Users can override these by setting client_id / client_secret in config.json. -const DEFAULT_CLIENT_ID: &str = "fX2JxdmntZWK0ixT"; -const DEFAULT_CLIENT_SECRET: &str = "1Nn9AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg=="; +// Built-in client credentials — must match a client that has lossless +// streaming entitlement. These are the same credentials tiddl uses +// (https://github.com/oskvr37/tiddl), which are known to work for FLAC. +const DEFAULT_CLIENT_ID: &str = "4N3n6Q1x95LL5K7p"; +const DEFAULT_CLIENT_SECRET: &str = "oKOXfJW371cX6xaZ0PyhgGNBdNLlBZd4AKKYougMjik="; fn client_id(config: &Config) -> &str { config.client_id.as_deref().unwrap_or(DEFAULT_CLIENT_ID) @@ -22,6 +23,10 @@ fn client_secret(config: &Config) -> &str { const AUTH_BASE: &str = "https://auth.tidal.com/v1/oauth2"; +/// Current auth generation. Bump this when changing client credentials +/// or auth method — forces users to re-authenticate. +const CURRENT_AUTH_GENERATION: u32 = 1; + pub fn config_path() -> PathBuf { dirs::config_dir() .unwrap_or_else(|| PathBuf::from(".")) @@ -62,6 +67,20 @@ fn is_token_valid(config: &Config) -> bool { } pub fn ensure_auth(config: &mut Config) -> Result<()> { + // Force re-auth whenever client credentials or auth method change. + if config.access_token.is_some() && config.auth_generation < CURRENT_AUTH_GENERATION { + eprintln!( + "[riptide] Auth upgrade (gen {} → {}) — re-authenticating for lossless streaming...", + config.auth_generation, CURRENT_AUTH_GENERATION, + ); + config.access_token = None; + config.refresh_token = None; + config.expires_at = None; + config.session_id = None; + config.auth_generation = CURRENT_AUTH_GENERATION; + save_config(config)?; + } + if is_token_valid(config) { // Re-fetch session info on each startup (session_id is ephemeral) if let Some(ref token) = config.access_token.clone() { @@ -74,7 +93,11 @@ pub fn ensure_auth(config: &mut Config) -> Result<()> { if config.refresh_token.is_some() { match try_refresh_blocking(config) { - Ok(()) => return Ok(()), + Ok(()) => { + config.auth_generation = CURRENT_AUTH_GENERATION; + save_config(config)?; + return Ok(()); + } Err(_) => { config.access_token = None; config.refresh_token = None; @@ -82,7 +105,10 @@ pub fn ensure_auth(config: &mut Config) -> Result<()> { } } - run_device_auth_flow(config) + run_device_auth_flow(config)?; + config.auth_generation = CURRENT_AUTH_GENERATION; + save_config(config)?; + Ok(()) } fn make_blocking_client() -> Result { @@ -99,7 +125,6 @@ fn fetch_session_info( let resp = client .get("https://api.tidal.com/v1/sessions") .bearer_auth(access_token) - .header("x-tidal-client-version", "2025.7.16") .send()?; if !resp.status().is_success() { @@ -124,12 +149,13 @@ fn try_refresh_blocking(config: &mut Config) -> Result<()> { .context("no refresh token")? .to_string(); - // Send client_id and client_secret as form body fields — tidalapi does NOT use Basic auth + // Use HTTP Basic Auth (like tiddl does). Sending client_secret as + // a form field grants a restricted token that only serves AAC. let resp = client .post(format!("{AUTH_BASE}/token")) + .basic_auth(client_id(config), Some(client_secret(config))) .form(&[ ("client_id", client_id(config)), - ("client_secret", client_secret(config)), ("grant_type", "refresh_token"), ("refresh_token", &refresh_token), ]) @@ -184,12 +210,14 @@ pub fn run_device_auth_flow(config: &mut Config) -> Result<()> { loop { std::thread::sleep(interval); - // client_id and client_secret go in the form body, not Basic auth + // Use HTTP Basic Auth for the token exchange — this grants + // a token with full lossless streaming privileges (like tiddl). + // Sending client_secret as a form field grants restricted AAC-only tokens. let result = client .post(format!("{AUTH_BASE}/token")) + .basic_auth(client_id(config), Some(client_secret(config))) .form(&[ ("client_id", client_id(config)), - ("client_secret", client_secret(config)), ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), ("device_code", auth.device_code.as_str()), ("scope", "r_usr w_usr w_sub"), @@ -229,9 +257,9 @@ pub async fn refresh_token_async(config: &Config, http: &reqwest::Client) -> Res Ok(http .post(format!("{AUTH_BASE}/token")) + .basic_auth(client_id(config), Some(client_secret(config))) .form(&[ ("client_id", client_id(config)), - ("client_secret", client_secret(config)), ("grant_type", "refresh_token"), ("refresh_token", refresh_token), ]) diff --git a/src/api/client.rs b/src/api/client.rs index 8b33793..279d1c1 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -11,7 +11,6 @@ use super::models::*; const BASE: &str = "https://api.tidal.com/v1"; const OPENAPI_BASE: &str = "https://openapi.tidal.com/v2"; -const CLIENT_VERSION: &str = "2025.7.16"; // Private types for the openapi.tidal.com/v2 JSON:API collection endpoints. #[derive(serde::Deserialize)] @@ -96,7 +95,6 @@ impl ApiClient { .http .get(&url) .bearer_auth(&token) - .header("x-tidal-client-version", CLIENT_VERSION) .query(&all_params) .send() .await @@ -111,7 +109,6 @@ impl ApiClient { .http .get(&url) .bearer_auth(&new_access) - .header("x-tidal-client-version", CLIENT_VERSION) .query(&all_params) .send() .await? @@ -227,7 +224,6 @@ impl ApiClient { self.http .post(&url) .bearer_auth(&token) - .header("x-tidal-client-version", CLIENT_VERSION) .query(&all_params) .form(form) .send() @@ -470,7 +466,6 @@ impl ApiClient { self.http .delete(&url) .bearer_auth(&token) - .header("x-tidal-client-version", CLIENT_VERSION) .query(&all_params) .send() .await @@ -506,8 +501,21 @@ impl ApiClient { } pub async fn get_stream_url(&self, track_id: u64) -> Result { - const QUALITIES: &[&str] = &["HI_RES_LOSSLESS", "LOSSLESS", "HIGH"]; + // Quality fallback chain for streaming. + // + // | Quality | Manifest MIME type | Container | Actual codec | + // |------------------|----------------------------|-------------|---------------| + // | LOSSLESS | application/vnd.tidal.bts | audio/flac | FLAC (raw) | + // | HI_RES_LOSSLESS | application/dash+xml | audio/mp4 | FLAC or AAC | + // | HIGH | application/vnd.tidal.bts | audio/mp4 | AAC | + // + // LOSSLESS → BTS manifest with `codecs: "flac"` → guaranteed raw FLAC. + // HI_RES_LOSSLESS → DASH manifest where codecs MAY be "flac" or "mp4a.40.2". + // Strategy: try LOSSLESS first (guaranteed FLAC), then HI_RES_LOSSLESS + // (only if its DASH codec is actually FLAC), then HIGH as last resort. + const QUALITIES: &[&str] = &["LOSSLESS", "HI_RES_LOSSLESS", "HIGH"]; let path = format!("/tracks/{track_id}/playbackinfopostpaywall"); + let debug = std::env::var("RIPTIDE_QUALITY_DEBUG").is_ok(); for &quality in QUALITIES { let result: Result = self.get( @@ -521,27 +529,124 @@ impl ApiClient { match result { Ok(info) => { + let mime = info.manifest_mime_type.clone(); + if debug { + let aq = info.audio_quality.as_deref().unwrap_or("?"); + eprintln!( + "[quality] track {track_id}: requested {quality}, \ + server returned manifestMimeType={mime}, \ + audioQuality={aq} (200 OK)", + ); + } + let bytes = base64::engine::general_purpose::STANDARD .decode(&info.manifest) .context("base64 decode of manifest")?; - match info.manifest_mime_type.as_str() { + + match mime.as_str() { "application/vnd.tidal.bts" => { let manifest: BtsManifest = serde_json::from_slice(&bytes) .context("parse BTS manifest")?; - if let Some(url) = manifest.urls.into_iter().next() { - return Ok(url); + + if manifest.urls.is_empty() { + if debug { + eprintln!("[quality] track {track_id}: BTS manifest has empty urls — skip"); + } + continue; + } + + let codec = manifest.codecs.as_deref().unwrap_or("(missing)"); + if debug { + eprintln!( + "[quality] track {track_id}: BTS codecs={codec}, \ + urls={} segment(s)", + manifest.urls.len(), + ); + } + + // BTS with FLAC codec → real lossless. + if manifest.is_flac() { + if debug { + eprintln!("[quality] track {track_id}: ✓ FLAC stream accepted ({quality})"); + } + if manifest.urls.len() == 1 { + return Ok(manifest.urls.into_iter().next().unwrap()); + } + let m3u8 = build_flac_m3u8(track_id, &manifest.urls); + return Ok(m3u8); + } + + // BTS with non-FLAC codec. + // For LOSSLESS requests: the API downgraded us → skip. + // For HIGH requests: this is expected AAC → accept. + if quality == "HIGH" { + if debug { + eprintln!("[quality] track {track_id}: accepting AAC stream (HIGH)"); + } + if let Some(url) = manifest.urls.into_iter().next() { + return Ok(url); + } + } else { + if debug { + eprintln!( + "[quality] track {track_id}: BTS codec is '{codec}' \ + (not flac) for {quality} request — falling through", + ); + } + continue; } } "application/dash+xml" => { let xml = String::from_utf8_lossy(&bytes); - let path = dash_to_hls(track_id, &xml) + + let sets = find_adaptation_sets(&xml); + let has_flac = sets.iter().any(|s| s.codecs == "flac"); + + if debug { + let codecs: Vec<&str> = sets.iter().map(|s| s.codecs.as_str()).collect(); + eprintln!( + "[quality] track {track_id}: DASH with {} AdaptationSet(s), \ + codecs={:?}, has_flac={has_flac}", + sets.len(), codecs, + ); + } + + if quality == "HI_RES_LOSSLESS" && !has_flac { + if debug { + eprintln!( + "[quality] track {track_id}: DASH has no FLAC codec \ + — falling through to next tier", + ); + } + continue; + } + + if debug { + eprintln!("[quality] track {track_id}: ✓ DASH/FLAC accepted ({quality})"); + } + let hls = dash_to_hls(track_id, &xml) .context("convert DASH manifest to HLS")?; - return Ok(path); + return Ok(hls); + } + _ => { + if debug { + eprintln!( + "[quality] track {track_id}: unknown manifest MIME type '{mime}' — skip", + ); + } + continue; } - _ => {} } } Err(e) => { + if debug { + let status = e.downcast_ref::() + .and_then(|re| re.status()); + eprintln!( + "[quality] track {track_id}: {quality} request failed \ + (status={status:?}): {e}", + ); + } let status = e.downcast_ref::() .and_then(|re| re.status()); let entitlement_denied = matches!( @@ -560,28 +665,112 @@ impl ApiClient { } } +// ── Multi-segment FLAC playlist ──────────────────────────────────────────────── + +/// Build a simple M3U8 playlist for multi-segment raw FLAC URLs so mpv can +/// play them gaplessly in sequence. +fn build_flac_m3u8(track_id: u64, urls: &[String]) -> String { + let mut m3u8 = String::from("#EXTM3U\n#EXT-X-VERSION:3\n"); + // Each segment is a standalone FLAC file; mpv handles concatenation natively. + for url in urls { + // We don't know exact durations upfront, but mpv will determine them + // from the FLAC stream headers. Use a generous placeholder. + m3u8.push_str("#EXTINF:10.0,\n"); + m3u8.push_str(url); + m3u8.push('\n'); + } + m3u8.push_str("#EXT-X-ENDLIST\n"); + + let playlist_path = format!("/tmp/riptide_hls_{track_id}.m3u8"); + let _ = std::fs::write(&playlist_path, &m3u8); + format!("http://127.0.0.1:{}/{track_id}.m3u8", crate::manifest::PORT) +} + // ── DASH → HLS conversion ───────────────────────────────────────────────────── +/// Represents a single `` found in the DASH manifest. +struct DashAdaptationSet { + /// The `codecs` attribute from the AdaptationSet or Representation element. + codecs: String, + /// Position of this AdaptationSet in the original XML (byte offset of opening tag). + _offset: usize, +} + +/// Find all AdaptationSet elements and their codec info. +/// Returns them so we can prefer FLAC over AAC. +fn find_adaptation_sets(xml: &str) -> Vec { + let mut sets = Vec::new(); + let mut rest = xml; + while let Some(pos) = rest.find("") { + rest = &fragment[end + "".len()..]; + } else { + break; + } + } + sets +} + /// Convert a Tidal DASH manifest to an HLS playlist served via local HTTP. +/// +/// When the manifest contains multiple AdaptationSets (e.g. AAC and FLAC), +/// we prefer the FLAC one so mpv plays real lossless audio. fn dash_to_hls(track_id: u64, xml: &str) -> anyhow::Result { - let init_url = dash_attr(xml, "initialization") + // If there are multiple AdaptationSets, try to find a FLAC one. + let adaptation_sets = find_adaptation_sets(xml); + + // Determine which region of the XML to use for attribute extraction. + // If we have a FLAC adaptation set, extract attributes from within it. + let search_region = if adaptation_sets.len() > 1 { + if let Some(flac_set) = adaptation_sets.iter().find(|s| s.codecs == "flac") { + // Extract from just this AdaptationSet's region of the XML. + let start = flac_set._offset; + let rest = &xml[start..]; + if let Some(end) = rest.find("") { + &rest[..end + "".len()] + } else { + xml + } + } else { + xml + } + } else { + xml + }; + + let codecs = dash_attr(search_region, "codecs").unwrap_or_default(); + + let init_url = dash_attr(search_region, "initialization") .context("no initialization URL in DASH manifest")?; - let media_tmpl = dash_attr(xml, "media") + let media_tmpl = dash_attr(search_region, "media") .context("no media template in DASH manifest")?; - let timescale: f64 = dash_attr(xml, "timescale") + let timescale: f64 = dash_attr(search_region, "timescale") .and_then(|s| s.parse().ok()) .unwrap_or(1.0); - let start_num: u64 = dash_attr(xml, "startNumber") + let start_num: u64 = dash_attr(search_region, "startNumber") .and_then(|s| s.parse().ok()) .unwrap_or(1); - let durations = dash_segment_durations(xml, timescale); + let durations = dash_segment_durations(search_region, timescale); anyhow::ensure!(!durations.is_empty(), "no segments in DASH manifest"); let target = durations.iter().cloned().fold(0f64, f64::max).ceil() as u64; let mut m3u8 = format!( - "#EXTM3U\n#EXT-X-VERSION:6\n#EXT-X-TARGETDURATION:{target}\n#EXT-X-MAP:URI=\"{init_url}\"\n" + "#EXTM3U\n#EXT-X-VERSION:6\n#EXT-X-TARGETDURATION:{target}\n" ); + // Include codec info so mpv knows what to expect. + if !codecs.is_empty() { + m3u8.push_str(&format!("#EXT-X-CODECS:{codecs}\n")); + } + m3u8.push_str(&format!("#EXT-X-MAP:URI=\"{init_url}\"\n")); + for (i, dur) in durations.iter().enumerate() { m3u8.push_str(&format!("#EXTINF:{dur:.5},\n")); m3u8.push_str(&media_tmpl.replace("$Number$", &(start_num + i as u64).to_string())); diff --git a/src/api/models.rs b/src/api/models.rs index 8af55c5..034b0f1 100644 --- a/src/api/models.rs +++ b/src/api/models.rs @@ -251,14 +251,52 @@ pub struct PlaybackInfo { #[serde(rename = "manifestMimeType")] pub manifest_mime_type: String, pub manifest: String, + #[allow(dead_code)] + #[serde(rename = "audioQuality", default)] + pub audio_quality: Option, + #[allow(dead_code)] + #[serde(rename = "audioMode", default)] + pub audio_mode: Option, + #[allow(dead_code)] + #[serde(rename = "bitDepth", default)] + pub bit_depth: Option, + #[allow(dead_code)] + #[serde(rename = "sampleRate", default)] + pub sample_rate: Option, } -/// Decoded content of a `application/vnd.tidal.bts` manifest +/// Decoded content of a `application/vnd.tidal.bts` manifest. +/// +/// For LOSSLESS quality the `mimeType` is `"audio/flac"` and `codecs` is `"flac"`. +/// For HIGH / LOW the codecs is something like `"mp4a.40.2"` (AAC). #[derive(Debug, Deserialize)] pub struct BtsManifest { + #[allow(dead_code)] + #[serde(rename = "mimeType", default)] + pub mime_type: Option, + #[serde(default)] + pub codecs: Option, + #[allow(dead_code)] + #[serde(rename = "encryptionType", default)] + pub encryption_type: Option, pub urls: Vec, } +impl BtsManifest { + /// True when the manifest's codec is FLAC (i.e. real lossless). + pub fn is_flac(&self) -> bool { + self.codecs.as_deref() == Some("flac") + } + + /// True when the manifest codec is an AAC variant. + #[allow(dead_code)] + pub fn is_aac(&self) -> bool { + self.codecs.as_deref() + .map(|c| c.starts_with("mp4a")) + .unwrap_or(false) + } +} + // ── Sessions ────────────────────────────────────────────────────────────────── /// Response from GET /sessions — needed after every fresh auth. @@ -318,4 +356,10 @@ pub struct Config { pub country_code: String, /// Tidal session UUID — required as `sessionId` query param on all v1 requests. pub session_id: Option, + /// Tracks which client credentials / auth method was used. + /// 0 = pre-migration (AAC-only, form-field auth, old client ID). + /// 1 = tiddl credentials + HTTP Basic Auth (lossless-capable). + /// Bumped to force re-auth when credentials or auth method change. + #[serde(default)] + pub auth_generation: u32, }