summaryrefslogtreecommitdiff
path: root/src/services
diff options
context:
space:
mode:
authorlittleblack111 <littleblack11111@gmail.com>2026-02-25 01:22:26 +0800
committerlittleblack111 <littleblack11111@gmail.com>2026-02-25 01:22:26 +0800
commita2f9b0b1256c04cf2fb850ef082e7a27e9e06cde (patch)
treebed1a473449aa532f6b689d044bf56afc3b69d5b /src/services
initmain
Diffstat (limited to 'src/services')
-rw-r--r--src/services/mod.rs21
-rw-r--r--src/services/tidal.rs524
2 files changed, 545 insertions, 0 deletions
diff --git a/src/services/mod.rs b/src/services/mod.rs
new file mode 100644
index 0000000..4691028
--- /dev/null
+++ b/src/services/mod.rs
@@ -0,0 +1,21 @@
+pub mod tidal;
+
+use crate::{error::Result, types::*};
+use async_trait::async_trait;
+
+#[async_trait]
+pub trait MusicService: Send + Sync {
+ async fn search(&self, query: &str, artist: Option<&str>) -> Result<SearchResults>;
+
+ async fn get_track_info(&self, track_id: u64) -> Result<TrackInfo>;
+
+ async fn get_album_info(&self, album_id: u64) -> Result<AlbumInfo>;
+
+ async fn get_playlist_info(&self, playlist_id: u64) -> Result<PlaylistInfo>;
+
+ async fn get_album_tracks(&self, album_id: u64) -> Result<Vec<TrackInfo>>;
+
+ async fn get_playlist_tracks(&self, playlist_id: u64) -> Result<Vec<TrackInfo>>;
+
+ async fn get_manifest(&self, track_id: u64, quality: AudioQuality) -> Result<DownloadManifest>;
+}
diff --git a/src/services/tidal.rs b/src/services/tidal.rs
new file mode 100644
index 0000000..a3c6781
--- /dev/null
+++ b/src/services/tidal.rs
@@ -0,0 +1,524 @@
+use crate::{
+ error::{DownloadError, Result},
+ services::MusicService,
+ types::*,
+};
+use async_trait::async_trait;
+use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
+use regex::Regex;
+use reqwest::Client;
+use serde::Deserialize;
+
+const API_BASE: &str = "https://triton.squid.wtf";
+const FALLBACK_APIS: &[&str] = &[
+ "https://aether.squid.wtf",
+ "https://zeus.squid.wtf",
+];
+
+#[derive(Debug, Deserialize)]
+struct ApiResponse<T> {
+ data: T,
+}
+
+#[derive(Debug, Deserialize)]
+struct SearchResponse {
+ items: Vec<TidalTrack>,
+}
+
+#[derive(Debug, Deserialize)]
+struct TidalTrack {
+ id: u64,
+ title: String,
+ #[serde(default)]
+ artist: Option<TidalArtist>,
+ #[serde(default)]
+ album: Option<TidalAlbum>,
+ #[serde(default)]
+ duration: Option<u32>,
+ #[serde(
+ rename = "audioQuality",
+ default
+ )]
+ audio_quality: Option<String>,
+}
+
+#[derive(Debug, Deserialize)]
+struct TidalArtist {
+ name: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct TidalAlbum {
+ title: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct TrackInfoData {
+ id: u64,
+ title: String,
+ #[serde(default)]
+ artist: Option<TidalArtist>,
+ #[serde(default)]
+ album: Option<TidalAlbum>,
+ #[serde(default)]
+ duration: Option<u32>,
+ #[serde(
+ rename = "audioQuality",
+ default
+ )]
+ audio_quality: Option<String>,
+}
+
+#[derive(Debug, Deserialize)]
+struct TrackManifestData {
+ #[serde(default)]
+ manifest: Option<String>,
+ #[serde(
+ rename = "bitDepth",
+ default
+ )]
+ bit_depth: Option<u8>,
+ #[serde(
+ rename = "sampleRate",
+ default
+ )]
+ sample_rate: Option<u32>,
+}
+
+#[derive(Debug, Deserialize)]
+struct AlbumData {
+ id: u64,
+ title: String,
+ #[serde(default)]
+ artist: Option<TidalArtist>,
+ #[serde(
+ rename = "numberOfTracks",
+ default
+ )]
+ number_of_tracks: Option<u32>,
+}
+
+#[derive(Debug, Deserialize)]
+struct PlaylistData {
+ id: u64,
+ title: String,
+ #[serde(default)]
+ creator: Option<TidalCreator>,
+ #[serde(
+ rename = "numberOfTracks",
+ default
+ )]
+ number_of_tracks: Option<u32>,
+}
+
+#[derive(Debug, Deserialize)]
+struct TidalCreator {
+ name: String,
+}
+
+#[derive(Debug, Deserialize)]
+struct ManifestJson {
+ #[serde(default)]
+ urls: Vec<String>,
+}
+
+#[derive(Clone)]
+pub struct TidalService {
+ client: Client,
+ api_index: usize,
+}
+
+impl TidalService {
+ pub fn new() -> Self {
+ TidalService {
+ client: Client::builder()
+ .user_agent("SquidDownloader/0.1.0")
+ .build()
+ .unwrap(),
+ api_index: 0,
+ }
+ }
+
+ fn get_api_base(&self) -> &str {
+ if self.api_index == 0 {
+ API_BASE
+ } else {
+ FALLBACK_APIS[self.api_index - 1]
+ }
+ }
+
+ fn parse_quality(quality_str: &str) -> AudioQuality {
+ match quality_str
+ .to_uppercase()
+ .as_str()
+ {
+ "HI_RES_LOSSLESS" | "HI_RES" => AudioQuality::HiResLossless,
+ "LOSSLESS" => AudioQuality::Lossless,
+ "HIGH" => AudioQuality::High,
+ "LOW" => AudioQuality::Low,
+ _ => AudioQuality::Auto,
+ }
+ }
+
+ fn map_track_to_info(track: TidalTrack) -> TrackInfo {
+ TrackInfo {
+ id: track.id,
+ title: track.title,
+ artist: track
+ .artist
+ .map(|a| a.name)
+ .unwrap_or_default(),
+ album: track
+ .album
+ .map(|a| a.title),
+ duration: track.duration,
+ quality: track
+ .audio_quality
+ .map(|q| Self::parse_quality(&q)),
+ cover_url: None,
+ }
+ }
+
+ fn parse_mpd_manifest(mpd_xml: &str) -> Result<Vec<String>> {
+ let mut segment_urls = Vec::new();
+ let mut base_url: Option<String> = None;
+
+ let re = Regex::new(r"<BaseURL>([^<]+)</BaseURL>").unwrap();
+ if let Some(caps) = re.captures(mpd_xml) {
+ base_url = Some(caps[1].to_string());
+ }
+
+ let segment_re = Regex::new(r#"media="([^"]+)""#).unwrap();
+ for caps in segment_re.captures_iter(mpd_xml) {
+ let segment = caps[1].to_string();
+ if let Some(ref base) = base_url {
+ let full_url = if base.ends_with('/') {
+ format!(
+ "{}{}",
+ base, segment
+ )
+ } else {
+ format!(
+ "{}/{}",
+ base, segment
+ )
+ };
+ segment_urls.push(full_url);
+ } else {
+ segment_urls.push(segment);
+ }
+ }
+
+ if segment_urls.is_empty() {
+ let seg_pattern = r#"<SegmentURL[^>]*media="([^"]+)""#;
+ let seg_re = Regex::new(seg_pattern).unwrap();
+ for caps in seg_re.captures_iter(mpd_xml) {
+ segment_urls.push(caps[1].to_string());
+ }
+ }
+
+ if segment_urls.is_empty() {
+ return Err(DownloadError::XmlParseError("Could not parse segment URLs from MPD manifest".to_string()));
+ }
+
+ Ok(segment_urls)
+ }
+
+ async fn make_request<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T> {
+ let url = format!(
+ "{}{}",
+ self.get_api_base(),
+ endpoint
+ );
+
+ let response = self
+ .client
+ .get(&url)
+ .send()
+ .await?;
+
+ if response.status() == 429 {
+ return Err(DownloadError::RateLimited);
+ }
+
+ let status = response.status();
+ if !status.is_success() {
+ return Err(
+ DownloadError::ServiceUnavailable(
+ format!(
+ "API returned status {}",
+ status
+ ),
+ ),
+ );
+ }
+
+ let text = response
+ .text()
+ .await?;
+
+ serde_json::from_str(&text).map_err(|e| DownloadError::JsonParseError(e))
+ }
+}
+
+impl Default for TidalService {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[async_trait]
+impl MusicService for TidalService {
+ async fn search(&self, query: &str, artist: Option<&str>) -> Result<SearchResults> {
+ let search_query = match artist {
+ Some(a) => format!(
+ "{} {}",
+ query, a
+ ),
+ None => query.to_string(),
+ };
+
+ let encoded = urlencoding::encode(&search_query);
+ let endpoint = format!(
+ "/search/?s={}",
+ encoded
+ );
+
+ let response: ApiResponse<SearchResponse> = self
+ .make_request(&endpoint)
+ .await?;
+
+ let tracks: Vec<TrackInfo> = response
+ .data
+ .items
+ .into_iter()
+ .map(Self::map_track_to_info)
+ .collect();
+
+ let filtered_tracks = if let Some(artist_name) = artist {
+ tracks
+ .into_iter()
+ .filter(
+ |t| {
+ strsim::jaro_winkler(
+ t.artist
+ .to_lowercase()
+ .as_str(),
+ artist_name
+ .to_lowercase()
+ .as_str(),
+ ) > 0.7
+ },
+ )
+ .collect()
+ } else {
+ tracks
+ };
+
+ Ok(
+ SearchResults {
+ tracks: filtered_tracks,
+ },
+ )
+ }
+
+ async fn get_track_info(&self, track_id: u64) -> Result<TrackInfo> {
+ let endpoint = format!(
+ "/info/?id={}",
+ track_id
+ );
+ let response: ApiResponse<TrackInfoData> = self
+ .make_request(&endpoint)
+ .await?;
+
+ Ok(
+ TrackInfo {
+ id: response
+ .data
+ .id,
+ title: response
+ .data
+ .title,
+ artist: response
+ .data
+ .artist
+ .map(|a| a.name)
+ .unwrap_or_default(),
+ album: response
+ .data
+ .album
+ .map(|a| a.title),
+ duration: response
+ .data
+ .duration,
+ quality: response
+ .data
+ .audio_quality
+ .map(|q| Self::parse_quality(&q)),
+ cover_url: None,
+ },
+ )
+ }
+
+ async fn get_album_info(&self, album_id: u64) -> Result<AlbumInfo> {
+ let endpoint = format!(
+ "/album/?id={}",
+ album_id
+ );
+ let response: ApiResponse<AlbumData> = self
+ .make_request(&endpoint)
+ .await?;
+
+ Ok(
+ AlbumInfo {
+ id: response
+ .data
+ .id,
+ title: response
+ .data
+ .title,
+ artist: response
+ .data
+ .artist
+ .map(|a| a.name)
+ .unwrap_or_default(),
+ track_count: response
+ .data
+ .number_of_tracks
+ .unwrap_or(0),
+ year: None,
+ cover_url: None,
+ },
+ )
+ }
+
+ async fn get_playlist_info(&self, playlist_id: u64) -> Result<PlaylistInfo> {
+ let endpoint = format!(
+ "/playlist/?id={}",
+ playlist_id
+ );
+ let response: ApiResponse<PlaylistData> = self
+ .make_request(&endpoint)
+ .await?;
+
+ Ok(
+ PlaylistInfo {
+ id: response
+ .data
+ .id,
+ title: response
+ .data
+ .title,
+ creator: response
+ .data
+ .creator
+ .map(|c| c.name)
+ .unwrap_or_default(),
+ track_count: response
+ .data
+ .number_of_tracks
+ .unwrap_or(0),
+ cover_url: None,
+ },
+ )
+ }
+
+ async fn get_album_tracks(&self, album_id: u64) -> Result<Vec<TrackInfo>> {
+ let endpoint = format!(
+ "/album/tracks/?id={}",
+ album_id
+ );
+ let response: ApiResponse<SearchResponse> = self
+ .make_request(&endpoint)
+ .await?;
+
+ Ok(
+ response
+ .data
+ .items
+ .into_iter()
+ .map(Self::map_track_to_info)
+ .collect(),
+ )
+ }
+
+ async fn get_playlist_tracks(&self, playlist_id: u64) -> Result<Vec<TrackInfo>> {
+ let endpoint = format!(
+ "/playlist/tracks/?id={}",
+ playlist_id
+ );
+ let response: ApiResponse<SearchResponse> = self
+ .make_request(&endpoint)
+ .await?;
+
+ Ok(
+ response
+ .data
+ .items
+ .into_iter()
+ .map(Self::map_track_to_info)
+ .collect(),
+ )
+ }
+
+ async fn get_manifest(&self, track_id: u64, quality: AudioQuality) -> Result<DownloadManifest> {
+ let endpoint = format!(
+ "/track/?id={}&quality={}",
+ track_id,
+ quality.as_str()
+ );
+ let response: ApiResponse<TrackManifestData> = self
+ .make_request(&endpoint)
+ .await?;
+
+ let manifest_b64 = response
+ .data
+ .manifest
+ .ok_or(DownloadError::NoDownloadUrl)?;
+ let manifest_decoded = BASE64.decode(&manifest_b64)?;
+ let manifest_str = String::from_utf8_lossy(&manifest_decoded);
+
+ let bit_depth = response
+ .data
+ .bit_depth;
+ let sample_rate = response
+ .data
+ .sample_rate;
+
+ if manifest_str.contains("application/dash+xml") || manifest_str.contains("<MPD") {
+ let segment_urls = Self::parse_mpd_manifest(&manifest_str)?;
+ Ok(
+ DownloadManifest {
+ url: None,
+ segment_urls: Some(segment_urls),
+ quality,
+ bit_depth,
+ sample_rate,
+ },
+ )
+ } else {
+ let manifest_data: ManifestJson = serde_json::from_str(&manifest_str)?;
+ let url = manifest_data
+ .urls
+ .into_iter()
+ .next()
+ .ok_or(DownloadError::NoDownloadUrl)?;
+
+ Ok(
+ DownloadManifest {
+ url: Some(url),
+ segment_urls: None,
+ quality,
+ bit_depth,
+ sample_rate,
+ },
+ )
+ }
+ }
+}
+
+mod urlencoding {
+ pub fn encode(s: &str) -> String {
+ url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
+ }
+}