diff --git a/package-lock.json b/package-lock.json index 32e747e6..cb0c8be2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "psysonic", - "version": "1.34.6", + "version": "1.34.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "psysonic", - "version": "1.34.6", + "version": "1.34.7", "dependencies": { "@fontsource-variable/dm-sans": "^5.2.8", "@fontsource-variable/figtree": "^5.2.10", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5df6456b..0a137668 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -250,6 +250,146 @@ async fn fetch_url_bytes(url: String) -> Result<(Vec, String), 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. +#[tauri::command] +async fn fetch_json_url(url: String) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| e.to_string())?; + let resp = client + .get(&url) + .header("User-Agent", "psysonic/1.0") + .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)] +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, +} + +/// 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. +#[tauri::command] +async fn fetch_icy_metadata(url: String) -> Result { + use futures_util::StreamExt; + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| e.to_string())?; + + let resp = client + .get(&url) + .header("Icy-MetaData", "1") + .header("User-Agent", "psysonic/1.0") + .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 }) +} + /// 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. @@ -1451,6 +1591,8 @@ pub fn run() { search_radio_browser, get_top_radio_stations, fetch_url_bytes, + fetch_json_url, + fetch_icy_metadata, download_track_offline, delete_offline_track, get_offline_cache_size, diff --git a/src/App.tsx b/src/App.tsx index 62299fdd..00c08c38 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -557,6 +557,7 @@ export default function App() { const effectiveTheme = useThemeScheduler(); const font = useFontStore(s => s.font); const uiScale = useFontStore(s => s.uiScale); + const setUiScale = useFontStore(s => s.setUiScale); const [exportPickerOpen, setExportPickerOpen] = useState(false); useEffect(() => { @@ -567,6 +568,14 @@ export default function App() { document.documentElement.setAttribute('data-font', font); }, [font]); + // TODO(ui-scale): UI scaling is disabled pending a cross-platform rework. + // Reset any stored non-100% value so users aren't stuck at a broken scale. + // When re-enabling: remove this effect AND re-enable the slider in Settings.tsx. + useEffect(() => { + if (uiScale !== 1.0) setUiScale(1.0); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { document.documentElement.style.zoom = String(uiScale); }, [uiScale]); diff --git a/src/api/azuracast.ts b/src/api/azuracast.ts new file mode 100644 index 00000000..b0fbab9c --- /dev/null +++ b/src/api/azuracast.ts @@ -0,0 +1,111 @@ +import { invoke } from '@tauri-apps/api/core'; + +// ─── AzuraCast API types ────────────────────────────────────────────────────── + +export interface AzuraCastSong { + artist: string; + title: string; + album: string; + art?: string; + text?: string; // "Artist - Title" combined +} + +export interface AzuraCastNowPlayingTrack { + song: AzuraCastSong; + duration: number; // seconds + elapsed: number; // seconds played so far + remaining: number; // seconds remaining + played_at?: number; +} + +export interface AzuraCastListeners { + current: number; + unique?: number; + total?: number; +} + +export interface AzuraCastNowPlaying { + now_playing: AzuraCastNowPlayingTrack; + playing_next?: { song: AzuraCastSong } | null; + song_history: Array<{ song: AzuraCastSong; played_at?: number }>; + listeners: AzuraCastListeners; + station?: { name: string; shortcode: string }; +} + +// ─── Detection helpers ──────────────────────────────────────────────────────── + +/** + * Try to derive an AzuraCast NowPlaying API URL from a stream URL. + * + * AzuraCast stream URLs follow the pattern: + * https:///listen//. + * + * Returns the candidate API URL or `null` if the pattern doesn't match. + */ +export function guessAzuraCastApiUrl(streamUrl: string): string | null { + try { + const u = new URL(streamUrl); + const parts = u.pathname.split('/').filter(Boolean); + // Expect: ['listen', '', ''] + if (parts.length >= 2 && parts[0] === 'listen') { + const shortcode = parts[1]; + return `${u.origin}/api/nowplaying/${shortcode}`; + } + } catch { + // ignore invalid URLs + } + return null; +} + +/** + * Check whether a homepage URL itself looks like an AzuraCast NowPlaying + * API endpoint and return the canonical URL to use. + * + * Accepts: + * - https:///api/nowplaying → all stations, we use as-is + * - https:///api/nowplaying/ → single station, use as-is + */ +export function normaliseAzuraCastHomepageUrl(homepageUrl: string): string | null { + try { + const u = new URL(homepageUrl); + if (/^\/api\/nowplaying(\/[^/]+)?$/.test(u.pathname)) { + return homepageUrl; + } + } catch { + // ignore + } + return null; +} + +/** + * Fetch AzuraCast NowPlaying data from the given API URL (bypasses CORS via + * the Rust backend). Returns `null` if the request fails or the response + * does not look like a valid AzuraCast payload. + * + * When the API URL points to the `/api/nowplaying` (array) endpoint, the + * first item in the array is returned. Otherwise the single-object form is + * used directly. + */ +export async function fetchAzuraCastNowPlaying(apiUrl: string): Promise { + try { + const raw: string = await invoke('fetch_json_url', { url: apiUrl }); + const parsed = JSON.parse(raw); + + // If the response is an array (all-stations endpoint), take the first item. + const obj: unknown = Array.isArray(parsed) ? parsed[0] : parsed; + if (!obj || typeof obj !== 'object') return null; + + const np = obj as Record; + // Minimal validation: must have `now_playing` with a `song` inside. + if ( + np.now_playing && + typeof np.now_playing === 'object' && + (np.now_playing as Record).song + ) { + return np as unknown as AzuraCastNowPlaying; + } + } catch { + // Network error, JSON parse error, etc. + } + return null; +} diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index e21c3a26..0819579f 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -16,6 +16,7 @@ import { useNavigate } from 'react-router-dom'; import { useLyricsStore } from '../store/lyricsStore'; import MarqueeText from './MarqueeText'; import LastfmIcon from './LastfmIcon'; +import { useRadioMetadata } from '../hooks/useRadioMetadata'; function formatTime(seconds: number): string { if (!seconds || isNaN(seconds)) return '0:00'; @@ -44,6 +45,9 @@ export default function PlayerBar() { const isRadio = !!currentRadio; + // 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; @@ -129,13 +133,23 @@ export default function PlayerBar() {
!isRadio && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)} /> !isRadio && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)} @@ -148,6 +162,11 @@ export default function PlayerBar() { ariaLabel={t('albumDetail.ratingLabel')} /> )} + {isRadio && radioMeta.listeners != null && ( + + {t('radio.listenerCount', { count: radioMeta.listeners })} + + )}
{currentTrack && !isRadio && (