use psysonic_core::user_agent::subsonic_wire_user_agent; pub const RADIO_PAGE_SIZE: u32 = 25; /// Search the radio-browser.info directory (needs User-Agent header — CORS would block WebView). // NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!. #[tauri::command] pub async fn search_radio_browser(query: String, offset: u32) -> Result, String> { let client = reqwest::Client::new(); let limit_s = RADIO_PAGE_SIZE.to_string(); let offset_s = offset.to_string(); let resp = client .get("https://de1.api.radio-browser.info/json/stations/search") .header("User-Agent", subsonic_wire_user_agent()) .query(&[ ("name", query.as_str()), ("hidebroken", "true"), ("limit", limit_s.as_str()), ("offset", offset_s.as_str()), ]) .send() .await .map_err(|e| e.to_string())? .error_for_status() .map_err(|e| e.to_string())?; resp.json::>().await.map_err(|e| e.to_string()) } /// Fetch top-voted stations from radio-browser.info for initial suggestions. // NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!. #[tauri::command] pub async fn get_top_radio_stations(offset: u32) -> Result, String> { let client = reqwest::Client::new(); let limit_s = RADIO_PAGE_SIZE.to_string(); let offset_s = offset.to_string(); let resp = client .get("https://de1.api.radio-browser.info/json/stations/topvote") .header("User-Agent", subsonic_wire_user_agent()) .query(&[("limit", limit_s.as_str()), ("offset", offset_s.as_str())]) .send() .await .map_err(|e| e.to_string())? .error_for_status() .map_err(|e| e.to_string())?; resp.json::>().await.map_err(|e| e.to_string()) } /// Fetch arbitrary URL bytes (e.g. radio station favicon) through Rust to bypass CORS. /// Returns (bytes, content_type). #[tauri::command] #[specta::specta] pub async fn fetch_url_bytes(url: String) -> Result<(Vec, String), String> { let client = reqwest::Client::builder() .user_agent(subsonic_wire_user_agent()) .timeout(std::time::Duration::from_secs(10)) .build() .map_err(|e| e.to_string())?; let resp = client .get(&url) .send() .await .map_err(|e| e.to_string())? .error_for_status() .map_err(|e| e.to_string())?; let content_type = resp .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .unwrap_or("image/jpeg") .split(';') .next() .unwrap_or("image/jpeg") .trim() .to_string(); let bytes = resp.bytes().await.map_err(|e| e.to_string())?; Ok((bytes.to_vec(), content_type)) } /// Fetch a JSON API endpoint through Rust to bypass CORS/WebView networking restrictions. /// Returns the response body as a UTF-8 string for parsing on the JS side. // NOT specta-collected: raw-JSON passthrough (FE JSON.parses the string) — kept hand-written on generate_handler! with the scrobbler/fetch family. #[tauri::command] pub async fn fetch_json_url(url: String) -> Result { let client = reqwest::Client::builder() .user_agent(subsonic_wire_user_agent()) .timeout(std::time::Duration::from_secs(10)) .build() .map_err(|e| e.to_string())?; let resp = client .get(&url) .header("Accept", "application/json") .send() .await .map_err(|e| e.to_string())? .error_for_status() .map_err(|e| e.to_string())?; let text = resp.text().await.map_err(|e| e.to_string())?; Ok(text) } /// ICY metadata response returned to the frontend. #[derive(serde::Serialize, specta::Type)] pub struct IcyMetadata { /// The `StreamTitle` from the inline ICY metadata block in the stream (e.g. `"Artist - Title"`). stream_title: Option, /// Value of the `icy-name` response header. icy_name: Option, /// Value of the `icy-genre` response header. icy_genre: Option, /// Value of the `icy-url` response header. icy_url: Option, /// Value of the `icy-description` response header. icy_description: Option, } /// Extract the first `File1=` stream URL from a PLS playlist file. pub fn parse_pls_stream_url(content: &str) -> Option { content.lines() .map(str::trim) .find(|l| l.to_lowercase().starts_with("file1=")) .and_then(|l| { let url = l[6..].trim(); (url.starts_with("http://") || url.starts_with("https://")) .then(|| url.to_string()) }) } /// Extract the first non-comment HTTP URL from an M3U/M3U8 playlist file. pub fn parse_m3u_stream_url(content: &str) -> Option { content.lines() .map(str::trim) .find(|l| !l.is_empty() && !l.starts_with('#') && (l.starts_with("http://") || l.starts_with("https://"))) .map(str::to_string) } /// If `url` points to a PLS or M3U playlist, fetch it and return the first /// stream URL it contains. Returns `None` for direct stream URLs. pub async fn resolve_playlist_url(client: &reqwest::Client, url: &str) -> Option { let path = url.split('?').next().unwrap_or(url).to_lowercase(); let is_pls = path.ends_with(".pls"); let is_m3u = path.ends_with(".m3u") || path.ends_with(".m3u8"); if !is_pls && !is_m3u { return None; } let resp = client .get(url) .timeout(std::time::Duration::from_secs(10)) .send() .await .ok()?; let ct = resp .headers() .get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or("") .to_lowercase(); let text = resp.text().await.ok()?; if is_pls || ct.contains("scpls") || ct.contains("pls+xml") { parse_pls_stream_url(&text) } else { parse_m3u_stream_url(&text) } } /// Fetch ICY in-stream metadata from a radio stream URL. /// /// Sends a GET request with `Icy-MetaData: 1` and reads just enough bytes /// (up to `icy-metaint` audio bytes plus the following metadata block) to /// extract the `StreamTitle`. The connection is dropped as soon as the /// first metadata chunk has been parsed, so bandwidth usage is minimal. /// /// If `url` is a PLS or M3U playlist file it is resolved to the first direct /// stream URL before the ICY request is made. #[tauri::command] #[specta::specta] pub async fn fetch_icy_metadata(url: String) -> Result { use futures_util::StreamExt; let client = reqwest::Client::builder() .user_agent(subsonic_wire_user_agent()) .timeout(std::time::Duration::from_secs(15)) .build() .map_err(|e| e.to_string())?; // Resolve PLS/M3U playlist files to their first direct stream URL. let url = resolve_playlist_url(&client, &url).await.unwrap_or(url); let resp = client .get(&url) .header("Icy-MetaData", "1") .send() .await .map_err(|e| e.to_string())?; // Harvest ICY headers before consuming the body. let headers = resp.headers(); let icy_name = headers.get("icy-name").and_then(|v| v.to_str().ok()).map(str::to_string); let icy_genre = headers.get("icy-genre").and_then(|v| v.to_str().ok()).map(str::to_string); let icy_url = headers.get("icy-url").and_then(|v| v.to_str().ok()).map(str::to_string); let icy_description = headers.get("icy-description").and_then(|v| v.to_str().ok()).map(str::to_string); let metaint: Option = headers .get("icy-metaint") .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse().ok()); // If the server doesn't advertise a metaint we can still return header info. let Some(metaint) = metaint else { return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description }); }; // Cap metaint at 64 KiB to avoid reading unreasonably large audio chunks. let metaint = metaint.min(65_536); let needed = metaint + 1; // +1 for the metadata-length byte let mut buf: Vec = Vec::with_capacity(needed + 256); let mut stream = resp.bytes_stream(); while buf.len() < needed { let Some(chunk) = stream.next().await else { break }; let chunk = chunk.map_err(|e| e.to_string())?; buf.extend_from_slice(&chunk); } if buf.len() < needed { // Stream ended before we reached the metadata block. return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description }); } // The byte immediately after `metaint` audio bytes encodes metadata length: // actual_bytes = length_byte * 16 let meta_len = buf[metaint] as usize * 16; if meta_len == 0 { return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description }); } // We may need to read a few more chunks to get the full metadata block. let total_needed = needed + meta_len; while buf.len() < total_needed { let Some(chunk) = stream.next().await else { break }; let chunk = chunk.map_err(|e| e.to_string())?; buf.extend_from_slice(&chunk); } let meta_start = needed; // index of first metadata byte let meta_end = (meta_start + meta_len).min(buf.len()); let meta_bytes = &buf[meta_start..meta_end]; // ICY metadata is Latin-1 encoded; convert to a Rust String lossily. let meta_str: String = meta_bytes .iter() .map(|&b| if b == 0 { '\0' } else { b as char }) .collect::(); // Parse StreamTitle='...' — value ends at the next unescaped single-quote. let stream_title = meta_str .split("StreamTitle='") .nth(1) .and_then(|s| { // Find closing quote that is NOT preceded by a backslash. let mut prev = '\0'; let mut end = s.len(); for (i, c) in s.char_indices() { if c == '\'' && prev != '\\' { end = i; break; } prev = c; } let title = s[..end].trim().to_string(); if title.is_empty() { None } else { Some(title) } }); Ok(IcyMetadata { stream_title, icy_name, icy_genre, icy_url, icy_description }) } /// Resolve a PLS or M3U playlist URL to its first direct stream URL. /// Returns the original URL unchanged if it is not a recognised playlist format /// or if the playlist cannot be fetched/parsed. #[tauri::command] #[specta::specta] pub async fn resolve_stream_url(url: String) -> String { let Ok(client) = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() else { return url; }; resolve_playlist_url(&client, &url).await.unwrap_or(url) } /// Default Audioscrobbler v2 endpoint (Last.fm). Other presets (Libre.fm, /// Rocksky, GNU FM, Maloja compat) pass their own `base_url`. const LASTFM_API_BASE: &str = "https://ws.audioscrobbler.com/2.0/"; /// Generic Audioscrobbler v2 transport. Provider-agnostic: the caller supplies /// the endpoint `base_url`, so Last.fm, Libre.fm, Rocksky, custom GNU FM and the /// Shared HTTP client for the Music Network provider transports /// (audioscrobbler / listenbrainz / maloja). A bounded timeout keeps a hung /// provider from leaving scrobble/probe/loved-sync promises unresolved — the /// sibling `fetch_*` commands in this module set the same kind of bound. fn provider_http_client() -> Result { reqwest::Client::builder() .timeout(std::time::Duration::from_secs(15)) .build() .map_err(|e| e.to_string()) } /// Maloja Audioscrobbler-compat surface all share this one command. /// /// `params` is a list of [key, value] pairs (method must be included). If `sign` /// is true an `api_sig` is computed (MD5 of sorted params + secret). If `get` is /// true a GET request is made, otherwise a form POST. // NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!. #[tauri::command] pub async fn audioscrobbler_request( base_url: String, params: Vec<[String; 2]>, sign: bool, get: bool, api_key: String, api_secret: String, ) -> Result { use std::collections::HashMap; let base = if base_url.trim().is_empty() { LASTFM_API_BASE.to_string() } else { base_url }; let mut map: HashMap = params.into_iter().map(|[k, v]| (k, v)).collect(); map.insert("api_key".into(), api_key.clone()); if sign { let mut keys: Vec = map.keys().cloned().collect(); keys.sort(); let sig_str: String = keys.iter() .filter(|k| k.as_str() != "format" && k.as_str() != "callback") .map(|k| format!("{}{}", k, map[k])) .collect::(); let sig_input = format!("{}{}", sig_str, api_secret); let digest = md5::compute(sig_input.as_bytes()); map.insert("api_sig".into(), format!("{:x}", digest)); } map.insert("format".into(), "json".into()); let client = provider_http_client()?; let resp = if get { client .get(&base) .query(&map) .header("User-Agent", subsonic_wire_user_agent()) .send() .await } else { client .post(&base) .form(&map) .header("User-Agent", subsonic_wire_user_agent()) .send() .await }.map_err(|e| e.to_string())?; let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; if let Some(err) = json.get("error") { return Err(format!("Audioscrobbler {} {}", err, json.get("message").and_then(|m| m.as_str()).unwrap_or(""))); } Ok(json) } /// Generic ListenBrainz transport. Used by both the direct /// `api.listenbrainz.org` preset and the Maloja `/apis/listenbrainz` compat /// surface — they differ only by `base_url`. Auth is a `Token` header. /// /// `path` is appended to `base_url` (e.g. `/1/submit-listens`). When `json_body` /// is present the request is a POST with that body; otherwise a GET. // NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!. #[tauri::command] pub async fn listenbrainz_request( base_url: String, path: String, auth_token: String, json_body: Option, ) -> Result { let url = format!("{}{}", base_url.trim_end_matches('/'), path); let client = provider_http_client()?; let mut req = if json_body.is_some() { client.post(&url) } else { client.get(&url) }; req = req .header("Authorization", format!("Token {}", auth_token)) .header("User-Agent", subsonic_wire_user_agent()); if let Some(body) = json_body { req = req.json(&body); } let resp = req.send().await.map_err(|e| e.to_string())?; let status = resp.status(); let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; if !status.is_success() { let msg = json.get("error").and_then(|m| m.as_str()).unwrap_or(""); return Err(format!("ListenBrainz {} {}", status.as_u16(), msg)); } Ok(json) } /// Generic Maloja native (`/apis/mlj_1`) transport. Protocol-agnostic JSON: /// the caller builds the body (including the Maloja key) and chooses the path. /// /// `path` is appended to `base_url`. When `json_body` is present the request is a /// POST with that body; otherwise a GET with `query` pairs. // NOT specta-collected: serde_json::Value in the command signature — specta rc.25 can't export it. Stays hand-written on generate_handler!. #[tauri::command] pub async fn maloja_request( base_url: String, path: String, query: Vec<[String; 2]>, json_body: Option, ) -> Result { let url = format!("{}{}", base_url.trim_end_matches('/'), path); let client = provider_http_client()?; let resp = if let Some(body) = json_body { client.post(&url).json(&body) } else { let q: Vec<(String, String)> = query.into_iter().map(|[k, v]| (k, v)).collect(); client.get(&url).query(&q) } .header("User-Agent", subsonic_wire_user_agent()) .send() .await .map_err(|e| e.to_string())?; let status = resp.status(); let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; if !status.is_success() { let msg = json .get("error") .and_then(|e| e.get("desc").or_else(|| e.get("type"))) .and_then(|m| m.as_str()) .unwrap_or(""); return Err(format!("Maloja {} {}", status.as_u16(), msg)); } Ok(json) } #[cfg(test)] mod tests { use super::*; use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; // ── parse_pls_stream_url ────────────────────────────────────────────────── #[test] fn parse_pls_returns_first_file_entry() { let pls = "[playlist]\nNumberOfEntries=2\nFile1=https://stream.example/audio\nTitle1=Foo\n"; assert_eq!( parse_pls_stream_url(pls), Some("https://stream.example/audio".to_string()) ); } #[test] fn parse_pls_is_case_insensitive_on_key() { let pls = "[playlist]\nfile1=http://stream.example/x\n"; assert_eq!(parse_pls_stream_url(pls), Some("http://stream.example/x".to_string())); } #[test] fn parse_pls_returns_none_for_non_http_url() { let pls = "File1=ftp://example/audio\n"; assert!(parse_pls_stream_url(pls).is_none()); } #[test] fn parse_pls_returns_none_when_no_file_entry() { let pls = "[playlist]\nNumberOfEntries=0\n"; assert!(parse_pls_stream_url(pls).is_none()); } #[test] fn parse_pls_skips_leading_whitespace_on_lines() { let pls = " File1=https://stream/audio\n"; assert_eq!(parse_pls_stream_url(pls), Some("https://stream/audio".to_string())); } // ── parse_m3u_stream_url ────────────────────────────────────────────────── #[test] fn parse_m3u_skips_extm3u_header_and_extinf_comments() { let m3u = "#EXTM3U\n#EXTINF:-1,Stream\nhttps://stream.example/audio\n"; assert_eq!( parse_m3u_stream_url(m3u), Some("https://stream.example/audio".to_string()) ); } #[test] fn parse_m3u_returns_first_url_in_order() { let m3u = "#EXTM3U\nhttps://first.example/a\nhttps://second.example/b\n"; assert_eq!(parse_m3u_stream_url(m3u), Some("https://first.example/a".to_string())); } #[test] fn parse_m3u_returns_none_when_no_url() { let m3u = "#EXTM3U\n#EXTINF:-1,Just a comment\n"; assert!(parse_m3u_stream_url(m3u).is_none()); } #[test] fn parse_m3u_returns_none_for_relative_paths() { let m3u = "track.mp3\n"; assert!(parse_m3u_stream_url(m3u).is_none()); } // ── resolve_playlist_url ────────────────────────────────────────────────── #[tokio::test(flavor = "multi_thread")] async fn resolve_returns_none_for_non_playlist_url() { let client = reqwest::Client::new(); // Direct stream URLs (without .pls/.m3u/.m3u8 extension) are returned as None. assert!(resolve_playlist_url(&client, "https://stream.example/audio").await.is_none()); } #[tokio::test(flavor = "multi_thread")] async fn resolve_returns_none_for_non_playlist_url_with_query() { let client = reqwest::Client::new(); assert!( resolve_playlist_url(&client, "https://stream.example/audio?foo=bar") .await .is_none() ); } #[tokio::test(flavor = "multi_thread")] async fn resolve_extracts_first_stream_from_pls() { let server = MockServer::start().await; Mock::given(method("GET")) .and(wm_path("/station.pls")) .respond_with( ResponseTemplate::new(200) .set_body_string("[playlist]\nFile1=https://stream.example/x\n"), ) .mount(&server) .await; let client = reqwest::Client::new(); let url = format!("{}/station.pls", server.uri()); assert_eq!( resolve_playlist_url(&client, &url).await, Some("https://stream.example/x".to_string()) ); } #[tokio::test(flavor = "multi_thread")] async fn resolve_extracts_first_stream_from_m3u8() { let server = MockServer::start().await; Mock::given(method("GET")) .and(wm_path("/station.m3u8")) .respond_with( ResponseTemplate::new(200) .set_body_string("#EXTM3U\n#EXTINF:-1,Stream\nhttps://stream.example/y\n"), ) .mount(&server) .await; let client = reqwest::Client::new(); let url = format!("{}/station.m3u8", server.uri()); assert_eq!( resolve_playlist_url(&client, &url).await, Some("https://stream.example/y".to_string()) ); } #[tokio::test(flavor = "multi_thread")] async fn resolve_dispatches_pls_when_content_type_says_so_even_with_other_extension() { // Some servers return .m3u extension but with audio/x-scpls Content-Type; // resolve_playlist_url honors the Content-Type for the parser choice. // set_body_raw lets us pin the Content-Type header — set_body_string // would force text/plain regardless of insert_header order. let server = MockServer::start().await; Mock::given(method("GET")) .and(wm_path("/weird.m3u")) .respond_with(ResponseTemplate::new(200).set_body_raw( "[playlist]\nFile1=https://pls.example/audio\n", "audio/x-scpls", )) .mount(&server) .await; let client = reqwest::Client::new(); let url = format!("{}/weird.m3u", server.uri()); assert_eq!( resolve_playlist_url(&client, &url).await, Some("https://pls.example/audio".to_string()) ); } // ── audioscrobbler_request ──────────────────────────────────────────────── #[tokio::test(flavor = "multi_thread")] async fn audioscrobbler_request_uses_custom_base_url() { let server = MockServer::start().await; Mock::given(method("GET")) .and(wm_path("/2.0/")) .respond_with(ResponseTemplate::new(200).set_body_raw( r#"{"similarartists":{"artist":[{"name":"Boards of Canada"}]}}"#, "application/json", )) .mount(&server) .await; let base = format!("{}/2.0/", server.uri()); let json = audioscrobbler_request( base, vec![ ["method".into(), "artist.getSimilar".into()], ["artist".into(), "Aphex Twin".into()], ], false, true, "key".into(), "secret".into(), ) .await .expect("request should succeed"); assert_eq!( json["similarartists"]["artist"][0]["name"].as_str(), Some("Boards of Canada") ); } #[tokio::test(flavor = "multi_thread")] async fn audioscrobbler_request_surfaces_api_error() { let server = MockServer::start().await; Mock::given(method("POST")) .and(wm_path("/2.0/")) .respond_with(ResponseTemplate::new(200).set_body_raw( r#"{"error":9,"message":"Invalid session key"}"#, "application/json", )) .mount(&server) .await; let base = format!("{}/2.0/", server.uri()); let err = audioscrobbler_request( base, vec![["method".into(), "track.scrobble".into()]], true, false, "key".into(), "secret".into(), ) .await .expect_err("api error should map to Err"); assert!(err.contains("Audioscrobbler 9"), "unexpected error: {err}"); } }