mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat(radio): PLS/M3U playlist resolution + ICY metadata for playlist streams
Resolves PLS and M3U/M3U8 playlist URLs to their first direct stream URL before playback and ICY metadata fetching. Stations configured with a .pls or .m3u URL (e.g. SomaFM, schizoid.in) now play correctly and report track metadata via ICY headers. - Rust: parse_pls_stream_url / parse_m3u_stream_url helpers - Rust: resolve_playlist_url (shared) + resolve_stream_url Tauri command - fetch_icy_metadata: auto-resolves playlist URLs before connecting - playerStore: playRadio() awaits resolve_stream_url before setting audio src Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
-1
@@ -3397,7 +3397,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "psysonic"
|
name = "psysonic"
|
||||||
version = "1.34.7"
|
version = "1.34.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"biquad",
|
"biquad",
|
||||||
"discord-rich-presence",
|
"discord-rich-presence",
|
||||||
|
|||||||
@@ -286,12 +286,70 @@ struct IcyMetadata {
|
|||||||
icy_description: Option<String>,
|
icy_description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract the first `File1=` stream URL from a PLS playlist file.
|
||||||
|
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.
|
||||||
|
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.
|
||||||
|
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)
|
||||||
|
.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.
|
/// Fetch ICY in-stream metadata from a radio stream URL.
|
||||||
///
|
///
|
||||||
/// Sends a GET request with `Icy-MetaData: 1` and reads just enough bytes
|
/// 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
|
/// (up to `icy-metaint` audio bytes plus the following metadata block) to
|
||||||
/// extract the `StreamTitle`. The connection is dropped as soon as the
|
/// extract the `StreamTitle`. The connection is dropped as soon as the
|
||||||
/// first metadata chunk has been parsed, so bandwidth usage is minimal.
|
/// 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]
|
#[tauri::command]
|
||||||
async fn fetch_icy_metadata(url: String) -> Result<IcyMetadata, String> {
|
async fn fetch_icy_metadata(url: String) -> Result<IcyMetadata, String> {
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
@@ -301,6 +359,9 @@ async fn fetch_icy_metadata(url: String) -> Result<IcyMetadata, String> {
|
|||||||
.build()
|
.build()
|
||||||
.map_err(|e| e.to_string())?;
|
.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
|
let resp = client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.header("Icy-MetaData", "1")
|
.header("Icy-MetaData", "1")
|
||||||
@@ -390,6 +451,20 @@ async fn fetch_icy_metadata(url: String) -> Result<IcyMetadata, String> {
|
|||||||
Ok(IcyMetadata { stream_title, icy_name, icy_genre, icy_url, icy_description })
|
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.
|
/// 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).
|
/// `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.
|
/// 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_url_bytes,
|
||||||
fetch_json_url,
|
fetch_json_url,
|
||||||
fetch_icy_metadata,
|
fetch_icy_metadata,
|
||||||
|
resolve_stream_url,
|
||||||
download_track_offline,
|
download_track_offline,
|
||||||
delete_offline_track,
|
delete_offline_track,
|
||||||
get_offline_cache_size,
|
get_offline_cache_size,
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export default function PlayerBar() {
|
|||||||
// Radio metadata (ICY or AzuraCast) — only active while a radio station is playing.
|
// Radio metadata (ICY or AzuraCast) — only active while a radio station is playing.
|
||||||
const radioMeta = useRadioMetadata(currentRadio ?? null);
|
const radioMeta = useRadioMetadata(currentRadio ?? null);
|
||||||
|
|
||||||
|
|
||||||
const isStarred = currentTrack
|
const isStarred = currentTrack
|
||||||
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
|
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
|
||||||
: false;
|
: false;
|
||||||
|
|||||||
@@ -764,7 +764,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── playRadio ────────────────────────────────────────────────────────────
|
// ── playRadio ────────────────────────────────────────────────────────────
|
||||||
playRadio: (station) => {
|
playRadio: async (station) => {
|
||||||
const { volume } = get();
|
const { volume } = get();
|
||||||
++playGeneration;
|
++playGeneration;
|
||||||
isAudioPaused = false;
|
isAudioPaused = false;
|
||||||
@@ -774,8 +774,12 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||||
// Stop Rust engine in case a regular track was playing.
|
// Stop Rust engine in case a regular track was playing.
|
||||||
invoke('audio_stop').catch(() => {});
|
invoke('audio_stop').catch(() => {});
|
||||||
|
// Resolve PLS/M3U playlist URLs to the actual stream URL before handing
|
||||||
|
// to HTML5 <audio> — the browser cannot play playlist files directly.
|
||||||
|
const streamUrl = await invoke<string>('resolve_stream_url', { url: station.streamUrl })
|
||||||
|
.catch(() => station.streamUrl);
|
||||||
// Play via HTML5 audio — browser handles reconnects, codec negotiation, buffering.
|
// Play via HTML5 audio — browser handles reconnects, codec negotiation, buffering.
|
||||||
radioAudio.src = station.streamUrl;
|
radioAudio.src = streamUrl;
|
||||||
radioAudio.volume = volume;
|
radioAudio.volume = volume;
|
||||||
radioAudio.play().catch((err: unknown) => {
|
radioAudio.play().catch((err: unknown) => {
|
||||||
console.error('[psysonic] radio HTML5 play failed:', err);
|
console.error('[psysonic] radio HTML5 play failed:', err);
|
||||||
|
|||||||
Reference in New Issue
Block a user