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, }