From c10068c63dc40c041656c198dba7f05c07a76671 Mon Sep 17 00:00:00 2001 From: Priyanshuuuu <167881010+bre4d777@users.noreply.github.com> Date: Wed, 1 Apr 2026 07:11:39 +0000 Subject: [PATCH 1/4] feat: add optional hifi -api for tidal From 48c6195d8e1a0e4fcd1f69d33701b9df77df3969 Mon Sep 17 00:00:00 2001 From: Priyanshuuuu <167881010+bre4d777@users.noreply.github.com> Date: Wed, 1 Apr 2026 07:12:41 +0000 Subject: [PATCH 2/4] feat: add optional hifi api for tidal From 3303ce9bf0ca2ad1e692ebefc48c65ca395d5a13 Mon Sep 17 00:00:00 2001 From: Priyanshuuuu <167881010+bre4d777@users.noreply.github.com> Date: Wed, 1 Apr 2026 07:13:36 +0000 Subject: [PATCH 3/4] feat: add optional hifi api for tidal --- config.example.toml | 25 +- src/config/sources/tidal.rs | 25 +- src/sources/tidal/hifi.rs | 457 +++++++++++++++++++++++++++++++++++ src/sources/tidal/manager.rs | 52 +++- src/sources/tidal/mod.rs | 3 +- 5 files changed, 550 insertions(+), 12 deletions(-) create mode 100644 src/sources/tidal/hifi.rs diff --git a/config.example.toml b/config.example.toml index 53b8e74..ee1df8d 100644 --- a/config.example.toml +++ b/config.example.toml @@ -68,9 +68,9 @@ enabled = true get_oauth_token = false [sources.youtube.clients] -search = ["TVHTML5_SIMPLY", "MUSIC_ANDROID", "ANDROID", "WEB"] -playback = ["ANDROID_VR", "TV_CAST", "WEB_EMBEDDED", "TV", "WEB", "IOS", "MWEB"] -resolve = ["TVHTML5_SIMPLY", "TVHTML5_UNPLUGGED", "WEB", "MWEB", "IOS", "ANDROID"] +search = ["TVHTML5_SIMPLY", "MUSIC_ANDROID", "MUSIC_WEB", "ANDROID", "WEB"] +playback = ["ANDROID_VR", "TV_CAST", "WEB_EMBEDDED", "TV", "WEB", "IOS"] +resolve = ["TVHTML5_SIMPLY", "TVHTML5_UNPLUGGED", "WEB", "IOS", "MUSIC_WEB", "ANDROID"] [sources.youtube.cipher] url = "https://cipher.kikkia.dev/" @@ -124,16 +124,27 @@ enabled = false # proxy = { url = "http://proxy:8080", username = "user", password = "pass" } [sources.tidal] -enabled = false +enabled = true country_code = "US" -quality = "LOSSLESS" # HI_RES_LOSSLESS | LOSSLESS | HIGH | LOW +quality = "LOSSLESS" # HI_RES_LOSSLESS | LOSSLESS | HIGH | LOW playlist_load_limit = 50 album_load_limit = 50 artist_load_limit = 20 get_oauth_token = false -# refresh_token = "your-refresh-token" # required for playback +# refresh_token = "your-refresh-token" # required for direct playback # proxy = { url = "http://proxy:8080", username = "user", password = "pass" } +# HiFi-RestAPI proxy (https://github.com/binimum/hifi-api) +# When enabled, all catalog lookups and playback are routed through the proxy +# instead of the direct Tidal API. OAuth / refresh_token are not required. +# Multiple URLs are supported; requests are distributed round-robin. +# Note: HI_RES_LOSSLESS returns a DASH manifest which is not supported ( it wasn't playable for me) — +# use LOSSLESS or lower for playback via HiFi. +[sources.tidal.hifi] +enabled = true +urls = ["http://localhost:8000"] # one or more HiFi-RestAPI base URLs +quality = "LOSSLESS" # HI_RES_LOSSLESS | LOSSLESS | HIGH | LOW + [sources.soundcloud] enabled = true search_limit = 10 @@ -290,4 +301,4 @@ spatial = true [metrics.prometheus] enabled = false -endpoint = "/metrics" +endpoint = "/metrics" \ No newline at end of file diff --git a/src/config/sources/tidal.rs b/src/config/sources/tidal.rs index 7f67dce..f2aae8a 100644 --- a/src/config/sources/tidal.rs +++ b/src/config/sources/tidal.rs @@ -6,6 +6,26 @@ use crate::config::sources::{ default_true, }; +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct TidalHifiConfig { + #[serde(default = "default_false")] + pub enabled: bool, + #[serde(default)] + pub urls: Vec, + #[serde(default = "default_tidal_quality")] + pub quality: String, +} + +impl Default for TidalHifiConfig { + fn default() -> Self { + Self { + enabled: false, + urls: Vec::new(), + quality: default_tidal_quality(), + } + } +} + #[derive(Debug, Deserialize, Serialize, Clone)] pub struct TidalConfig { #[serde(default = "default_true")] @@ -24,6 +44,8 @@ pub struct TidalConfig { #[serde(default = "default_limit_20")] pub artist_load_limit: usize, pub proxy: Option, + #[serde(default)] + pub hifi: TidalHifiConfig, } impl Default for TidalConfig { @@ -38,6 +60,7 @@ impl Default for TidalConfig { album_load_limit: 50, artist_load_limit: 20, proxy: None, + hifi: TidalHifiConfig::default(), } } -} +} \ No newline at end of file diff --git a/src/sources/tidal/hifi.rs b/src/sources/tidal/hifi.rs new file mode 100644 index 0000000..00f94d5 --- /dev/null +++ b/src/sources/tidal/hifi.rs @@ -0,0 +1,457 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use base64::{Engine as _, engine::general_purpose}; +use tracing::{debug, warn}; + +use super::{ + client::TidalClient, + model::{Manifest, PlaybackInfo}, + track::TidalTrack, +}; +use crate::{ + common::types::AudioFormat, + protocol::tracks::{LoadResult, PlaylistData, PlaylistInfo, Track, TrackInfo}, + sources::playable_track::BoxedTrack, +}; + +pub struct TidalHifiClient { + pub quality: String, + client: Arc, + tidal_client: Arc, + urls: Vec, + idx: AtomicUsize, +} + +impl TidalHifiClient { + pub fn new( + client: Arc, + tidal_client: Arc, + urls: Vec, + quality: String, + ) -> Self { + Self { + quality, + client, + tidal_client, + urls, + idx: AtomicUsize::new(0), + } + } + + fn base_url(&self) -> String { + let i = self.idx.fetch_add(1, Ordering::Relaxed) % self.urls.len(); + self.urls[i].trim_end_matches('/').to_owned() + } + + async fn get(&self, path: &str) -> Option { + let url = format!("{}{}", self.base_url(), path); + debug!("HiFi: GET {}", url); + let resp = self.client.get(&url).send().await.ok()?; + if !resp.status().is_success() { + debug!("HiFi: {} {}", resp.status(), resp.text().await.unwrap_or_default()); + return None; + } + resp.json().await.ok() + } + + fn parse_track(&self, item: &serde_json::Value) -> Option { + let id = item.get("id")?.as_u64()?.to_string(); + let title = item + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown Title") + .to_string(); + + let artists = item + .get("artists") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|x| x.get("name").and_then(|n| n.as_str())) + .collect::>() + .join(", ") + }) + .or_else(|| { + item.get("artist") + .and_then(|a| a.get("name")) + .and_then(|v| v.as_str()) + .map(|s| s.to_owned()) + }) + .unwrap_or_else(|| "Unknown Artist".to_owned()); + + let length = item.get("duration").and_then(|v| v.as_u64()).unwrap_or(0) * 1000; + + let isrc = item + .get("isrc") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_owned()); + + let artwork_url = item + .get("album") + .and_then(|a| a.get("cover")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| { + format!( + "https://resources.tidal.com/images/{}/1280x1280.jpg", + s.replace("-", "/") + ) + }); + + let url = item + .get("url") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.replace("http://", "https://")); + + Some(TrackInfo { + title, + author: artists, + length, + identifier: id, + is_stream: false, + uri: url, + artwork_url, + isrc, + source_name: "tidal".to_owned(), + is_seekable: true, + position: 0, + }) + } + + pub async fn load_track(&self, id: &str) -> LoadResult { + let data = match self.get(&format!("/info/?id={}", id)).await { + Some(d) => d, + None => return LoadResult::Empty {}, + }; + let track_obj = data.get("data").unwrap_or(&data); + self.parse_track(track_obj) + .map(|i| LoadResult::Track(Track::new(i))) + .unwrap_or(LoadResult::Empty {}) + } + + pub async fn load_album(&self, id: &str, limit: usize) -> LoadResult { + let data = match self + .get(&format!("/album/?id={}&limit={}", id, limit.clamp(1, 500))) + .await + { + Some(d) => d, + None => return LoadResult::Empty {}, + }; + + let album = data.get("data").unwrap_or(&data); + let title = album + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_owned(); + let total = album + .get("numberOfTracks") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + let mut tracks = Vec::new(); + if let Some(items) = album.get("items").and_then(|v| v.as_array()) { + for item in items { + let track_obj = item.get("item").unwrap_or(item); + if let Some(info) = self.parse_track(track_obj) { + tracks.push(Track::new(info)); + } + } + } + + if tracks.is_empty() { + return LoadResult::Empty {}; + } + + LoadResult::Playlist(PlaylistData { + info: PlaylistInfo { name: title, selected_track: -1 }, + plugin_info: serde_json::json!({ + "type": "album", + "url": format!("https://tidal.com/browse/album/{}", id), + "totalTracks": total + }), + tracks, + }) + } + + pub async fn load_playlist(&self, id: &str, limit: usize) -> LoadResult { + let data = match self + .get(&format!("/playlist/?id={}&limit={}", id, limit.clamp(1, 500))) + .await + { + Some(d) => d, + None => return LoadResult::Empty {}, + }; + + let playlist = data.get("playlist").unwrap_or(&data); + let title = playlist + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_owned(); + let total = playlist + .get("numberOfTracks") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + let mut tracks = Vec::new(); + if let Some(items) = data.get("items").and_then(|v| v.as_array()) { + for item in items { + let track_obj = item.get("item").unwrap_or(item); + if let Some(info) = self.parse_track(track_obj) { + tracks.push(Track::new(info)); + } + } + } + + if tracks.is_empty() { + return LoadResult::Empty {}; + } + + LoadResult::Playlist(PlaylistData { + info: PlaylistInfo { name: title, selected_track: -1 }, + plugin_info: serde_json::json!({ + "type": "playlist", + "url": format!("https://tidal.com/browse/playlist/{}", id), + "totalTracks": total + }), + tracks, + }) + } + + pub async fn load_mix(&self, id: &str, name_override: Option) -> LoadResult { + let data = match self.get(&format!("/mix/?id={}", id)).await { + Some(d) => d, + None => return LoadResult::Empty {}, + }; + + let name = name_override + .or_else(|| { + data.get("mix") + .and_then(|m| m.get("title")) + .and_then(|v| v.as_str()) + .map(|s| s.to_owned()) + }) + .unwrap_or_else(|| format!("Mix: {}", id)); + + let mut tracks = Vec::new(); + if let Some(items) = data.get("items").and_then(|v| v.as_array()) { + for item in items { + if let Some(info) = self.parse_track(item) { + tracks.push(Track::new(info)); + } + } + } + + if tracks.is_empty() { + return LoadResult::Empty {}; + } + + LoadResult::Playlist(PlaylistData { + info: PlaylistInfo { name, selected_track: -1 }, + plugin_info: serde_json::json!({ + "type": "playlist", + "url": format!("https://tidal.com/browse/mix/{}", id), + "totalTracks": tracks.len() + }), + tracks, + }) + } + + pub async fn load_recommendations(&self, id: &str) -> LoadResult { + let data = match self + .get(&format!("/recommendations/?id={}", id)) + .await + { + Some(d) => d, + None => return LoadResult::Empty {}, + }; + + let inner = data.get("data").unwrap_or(&data); + let mut tracks = Vec::new(); + if let Some(items) = inner.get("items").and_then(|v| v.as_array()) { + for item in items { + let track_obj = item.get("track").unwrap_or(item); + if let Some(info) = self.parse_track(track_obj) { + tracks.push(Track::new(info)); + } + } + } + + if tracks.is_empty() { + return LoadResult::Empty {}; + } + + LoadResult::Playlist(PlaylistData { + info: PlaylistInfo { + name: "Tidal Recommendations".to_owned(), + selected_track: -1, + }, + plugin_info: serde_json::json!({ + "type": "playlist", + "url": format!("https://tidal.com/browse/track/{}", id), + "totalTracks": tracks.len() + }), + tracks, + }) + } + + pub async fn load_artist_top_tracks(&self, id: &str) -> LoadResult { + let data = match self + .get(&format!("/artist/?f={}&skip_tracks=true", id)) + .await + { + Some(d) => d, + None => return LoadResult::Empty {}, + }; + + let name = data + .get("albums") + .and_then(|a| a.get("items")) + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|first| first.get("artists")) + .and_then(|a| a.as_array()) + .and_then(|arr| arr.first()) + .and_then(|a| a.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("Unknown Artist") + .to_owned(); + + let mut tracks = Vec::new(); + if let Some(items) = data.get("tracks").and_then(|v| v.as_array()) { + for item in items { + if let Some(info) = self.parse_track(item) { + tracks.push(Track::new(info)); + } + } + } + + if tracks.is_empty() { + return LoadResult::Empty {}; + } + + LoadResult::Playlist(PlaylistData { + info: PlaylistInfo { + name: format!("{}'s Top Tracks", name), + selected_track: -1, + }, + plugin_info: serde_json::json!({ + "type": "artist", + "url": format!("https://tidal.com/browse/artist/{}", id), + "totalTracks": tracks.len() + }), + tracks, + }) + } + + pub async fn search(&self, query: &str) -> LoadResult { + let encoded = urlencoding::encode(query); + let data = match self + .get(&format!("/search/?s={}&limit=10", encoded)) + .await + { + Some(d) => d, + None => return LoadResult::Empty {}, + }; + + let inner = data.get("data").unwrap_or(&data); + let mut tracks = Vec::new(); + if let Some(items) = inner.get("items").and_then(|v| v.as_array()) { + for item in items { + if let Some(info) = self.parse_track(item) { + tracks.push(Track::new(info)); + } + } + } + + if tracks.is_empty() { + LoadResult::Empty {} + } else { + LoadResult::Search(tracks) + } + } + + pub async fn get_playback_track(&self, id: &str) -> Option { + const FALLBACK_CHAIN: &[&str] = &["LOSSLESS", "HIGH", "LOW"]; + + let start = match self.quality.as_str() { + "HI_RES_LOSSLESS" | "LOSSLESS" => 0, + "HIGH" => 1, + _ => 2, + }; + + for &quality in &FALLBACK_CHAIN[start..] { + let data = match self + .get(&format!("/track/?id={}&quality={}", id, quality)) + .await + { + Some(d) => d, + None => continue, + }; + + let info_val = data.get("data").unwrap_or(&data); + let info: PlaybackInfo = match serde_json::from_value(info_val.clone()) { + Ok(i) => i, + Err(e) => { + warn!("HiFi: Failed to parse playback info for {} at {}: {}", id, quality, e); + continue; + } + }; + + if info.manifest_mime_type == "application/dash+xml" { + debug!("HiFi: track {} returned DASH at {}; skipping", id, quality); + continue; + } + + let decoded = match general_purpose::STANDARD.decode(&info.manifest) { + Ok(d) => d, + Err(e) => { + warn!("HiFi: Failed to decode manifest for {} at {}: {}", id, quality, e); + continue; + } + }; + + let manifest: Manifest = match serde_json::from_slice(&decoded) { + Ok(m) => m, + Err(e) => { + warn!("HiFi: Failed to parse manifest JSON for {} at {}: {}", id, quality, e); + continue; + } + }; + + let stream_url = match manifest.urls.into_iter().next() { + Some(u) => u, + None => { + warn!("HiFi: No stream URL in manifest for track {} at {}", id, quality); + continue; + } + }; + + if quality != self.quality.as_str() { + debug!("HiFi: track {} serving at {} (configured: {})", id, quality, self.quality); + } + + let mut kind = AudioFormat::from_url(&stream_url); + if kind == AudioFormat::Unknown { + kind = match quality { + "LOSSLESS" => AudioFormat::Mp4, + _ => AudioFormat::Aac, + }; + } + + return Some(Arc::new(TidalTrack { + identifier: id.to_owned(), + stream_url, + kind, + client: self.tidal_client.clone(), + })); + } + + warn!("HiFi: No playable quality found for track {}", id); + None + } +} \ No newline at end of file diff --git a/src/sources/tidal/manager.rs b/src/sources/tidal/manager.rs index c8df6a7..099776c 100644 --- a/src/sources/tidal/manager.rs +++ b/src/sources/tidal/manager.rs @@ -21,6 +21,7 @@ use tracing::{debug, warn}; use super::{ client::TidalClient, + hifi::TidalHifiClient, model::{Manifest, PlaybackInfo}, oauth::TidalOAuth, token::TidalTokenTracker, @@ -41,6 +42,7 @@ fn url_regex() -> &'static Regex { pub struct TidalSource { pub client: Arc, + hifi: Option>, playlist_load_limit: usize, album_load_limit: usize, artist_load_limit: usize, @@ -51,7 +53,7 @@ impl TidalSource { config: Option, http_client: Arc, ) -> Result { - let (country, quality, p_limit, a_limit, art_limit, refresh_token, get_oauth_token) = + let (country, quality, p_limit, a_limit, art_limit, refresh_token, get_oauth_token, hifi_cfg) = if let Some(c) = config { ( c.country_code, @@ -61,6 +63,7 @@ impl TidalSource { c.artist_load_limit, c.refresh_token, c.get_oauth_token, + Some(c.hifi), ) } else { ( @@ -71,6 +74,7 @@ impl TidalSource { 0, None, false, + None, ) }; @@ -93,8 +97,20 @@ impl TidalSource { quality, )); + let hifi = hifi_cfg + .filter(|h| h.enabled && !h.urls.is_empty()) + .map(|h| { + Arc::new(TidalHifiClient::new( + client.inner.clone(), + client.clone(), + h.urls, + h.quality, + )) + }); + Ok(Self { client, + hifi, playlist_load_limit: p_limit, album_load_limit: a_limit, artist_load_limit: art_limit, @@ -161,6 +177,9 @@ impl TidalSource { } async fn get_track_data(&self, id: &str) -> LoadResult { + if let Some(hifi) = &self.hifi { + return hifi.load_track(id).await; + } match self.client.get_json(&format!("/tracks/{id}")).await { Ok(data) => self .parse_track(&data) @@ -171,6 +190,14 @@ impl TidalSource { } async fn get_album_or_playlist(&self, id: &str, type_str: &str) -> LoadResult { + if let Some(hifi) = &self.hifi { + return if type_str == "album" { + hifi.load_album(id, self.album_load_limit).await + } else { + hifi.load_playlist(id, self.playlist_load_limit).await + }; + } + let info_data = match self.client.get_json(&format!("/{type_str}s/{id}")).await { Ok(d) => d, Err(_) => return LoadResult::Empty {}, @@ -226,6 +253,10 @@ impl TidalSource { } async fn get_mix(&self, id: &str, name_override: Option) -> LoadResult { + if let Some(hifi) = &self.hifi { + return hifi.load_mix(id, name_override).await; + } + let data = match self .client .get_json(&format!("/mixes/{id}/items?limit=100")) @@ -260,7 +291,6 @@ impl TidalSource { } async fn resolve_by_isrc(&self, isrc: &str) -> LoadResult { - // v2 only; requires OAuth Bearer. scraper token won't work here. let token = match self.client.token_tracker.get_oauth_token().await { Some(t) => t, None => { @@ -317,6 +347,10 @@ impl TidalSource { } async fn search(&self, query: &str) -> LoadResult { + if let Some(hifi) = &self.hifi { + return hifi.search(query).await; + } + let encoded = urlencoding::encode(query); match self .client @@ -343,6 +377,10 @@ impl TidalSource { } async fn get_recommendations(&self, id: &str) -> LoadResult { + if let Some(hifi) = &self.hifi { + return hifi.load_recommendations(id).await; + } + if let Ok(data) = self.client.get_json(&format!("/tracks/{id}")).await && let Some(mix_id) = data.pointer("/mixes/TRACK_MIX").and_then(|v| v.as_str()) { @@ -354,6 +392,10 @@ impl TidalSource { } async fn get_artist_top_tracks(&self, id: &str) -> LoadResult { + if let Some(hifi) = &self.hifi { + return hifi.load_artist_top_tracks(id).await; + } + let info_data = match self.client.get_json(&format!("/artists/{id}")).await { Ok(d) => d, Err(_) => return LoadResult::Empty {}, @@ -493,6 +535,10 @@ impl SourcePlugin for TidalSource { identifier.to_owned() }; + if let Some(hifi) = &self.hifi { + return hifi.get_playback_track(&id).await; + } + let token = match self.client.token_tracker.get_oauth_token().await { Some(t) => t, None => { @@ -582,4 +628,4 @@ impl SourcePlugin for TidalSource { client: self.client.clone(), })) } -} +} \ No newline at end of file diff --git a/src/sources/tidal/mod.rs b/src/sources/tidal/mod.rs index c86329a..b641b82 100644 --- a/src/sources/tidal/mod.rs +++ b/src/sources/tidal/mod.rs @@ -1,9 +1,10 @@ pub mod client; pub mod error; +pub mod hifi; pub mod manager; pub mod model; pub mod oauth; pub mod token; pub mod track; -pub use manager::TidalSource; +pub use manager::TidalSource; \ No newline at end of file From ade89725796ce1bc3b1d8abd96b7c304e49ec587 Mon Sep 17 00:00:00 2001 From: Priyanshuuuu <167881010+bre4d777@users.noreply.github.com> Date: Wed, 1 Apr 2026 07:16:15 +0000 Subject: [PATCH 4/4] chore: revert config to default --- config.example.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config.example.toml b/config.example.toml index ee1df8d..eb123a4 100644 --- a/config.example.toml +++ b/config.example.toml @@ -68,9 +68,9 @@ enabled = true get_oauth_token = false [sources.youtube.clients] -search = ["TVHTML5_SIMPLY", "MUSIC_ANDROID", "MUSIC_WEB", "ANDROID", "WEB"] -playback = ["ANDROID_VR", "TV_CAST", "WEB_EMBEDDED", "TV", "WEB", "IOS"] -resolve = ["TVHTML5_SIMPLY", "TVHTML5_UNPLUGGED", "WEB", "IOS", "MUSIC_WEB", "ANDROID"] +search = ["TVHTML5_SIMPLY", "MUSIC_ANDROID", "ANDROID", "WEB"] +playback = ["ANDROID_VR", "TV_CAST", "WEB_EMBEDDED", "TV", "WEB", "IOS", "MWEB"] +resolve = ["TVHTML5_SIMPLY", "TVHTML5_UNPLUGGED", "WEB", "MWEB", "IOS", "ANDROID"] [sources.youtube.cipher] url = "https://cipher.kikkia.dev/"