Files
Psychotoxical-psysonic/src-tauri/crates/psysonic-integration/src/remote.rs
T

673 lines
25 KiB
Rust

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<Vec<serde_json::Value>, 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::<Vec<serde_json::Value>>().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<Vec<serde_json::Value>, 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::<Vec<serde_json::Value>>().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<u8>, 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<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)
.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<String>,
/// Value of the `icy-name` response header.
icy_name: Option<String>,
/// Value of the `icy-genre` response header.
icy_genre: Option<String>,
/// Value of the `icy-url` response header.
icy_url: Option<String>,
/// Value of the `icy-description` response header.
icy_description: Option<String>,
}
/// Extract the first `File1=` stream URL from a PLS playlist file.
pub fn parse_pls_stream_url(content: &str) -> Option<String> {
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<String> {
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<String> {
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<IcyMetadata, String> {
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<usize> = 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<u8> = 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::<String>();
// 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, String> {
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<serde_json::Value, String> {
use std::collections::HashMap;
let base = if base_url.trim().is_empty() { LASTFM_API_BASE.to_string() } else { base_url };
let mut map: HashMap<String, String> = params.into_iter().map(|[k, v]| (k, v)).collect();
map.insert("api_key".into(), api_key.clone());
if sign {
let mut keys: Vec<String> = 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::<String>();
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<serde_json::Value>,
) -> Result<serde_json::Value, String> {
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<serde_json::Value>,
) -> Result<serde_json::Value, String> {
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}");
}
}