diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 975212a5..bc99f063 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3397,7 +3397,7 @@ dependencies = [ [[package]] name = "psysonic" -version = "1.34.7" +version = "1.34.8" dependencies = [ "biquad", "discord-rich-presence", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index be04e7b2..891f0033 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -286,12 +286,70 @@ struct IcyMetadata { icy_description: Option, } +/// Extract the first `File1=` stream URL from a PLS playlist file. +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. +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. +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) + .header("User-Agent", "psysonic/1.0") + .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] async fn fetch_icy_metadata(url: String) -> Result { use futures_util::StreamExt; @@ -301,6 +359,9 @@ async fn fetch_icy_metadata(url: String) -> Result { .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") @@ -390,6 +451,20 @@ async fn fetch_icy_metadata(url: String) -> Result { 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] +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) +} + /// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions. /// `params` is a list of [key, value] pairs (method must be included). /// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made. @@ -1640,6 +1715,7 @@ pub fn run() { fetch_url_bytes, fetch_json_url, fetch_icy_metadata, + resolve_stream_url, download_track_offline, delete_offline_track, get_offline_cache_size, diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index da640700..ed34937c 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -86,6 +86,7 @@ export default function PlayerBar() { // Radio metadata (ICY or AzuraCast) — only active while a radio station is playing. const radioMeta = useRadioMetadata(currentRadio ?? null); + const isStarred = currentTrack ? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred) : false; diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 2e51a1da..9d5387dc 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -764,7 +764,7 @@ export const usePlayerStore = create()( }, // ── playRadio ──────────────────────────────────────────────────────────── - playRadio: (station) => { + playRadio: async (station) => { const { volume } = get(); ++playGeneration; isAudioPaused = false; @@ -774,8 +774,12 @@ export const usePlayerStore = create()( if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null; // Stop Rust engine in case a regular track was playing. invoke('audio_stop').catch(() => {}); + // Resolve PLS/M3U playlist URLs to the actual stream URL before handing + // to HTML5