mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
72030f17fd
First Phase F slice. Splits the 1333-LOC `api/subsonic.ts` along its two most obvious axes: - `subsonicTypes.ts` — all ~24 exported interfaces + type aliases (album/song/artist/playlist/directory/genre/now-playing/radio, random-songs filters, three statistics shapes, search + starred results, AlbumInfo, structured-lyrics types, etc.) plus the `RADIO_PAGE_SIZE` constant. - `subsonicClient.ts` — token-auth + `getClient` + `api<T>()` + `libraryFilterParams` + `secureRandomSalt` / `getAuthParams` / `SUBSONIC_CLIENT`. The credential-bearing API helpers (`pingWithCredentials`, `apiWithCredentials`, `restBaseFromUrl`, `probeInstantMixWithCredentials`) stay in `subsonic.ts` for now — they could move into the client module in a follow-up. 66 external call sites migrated to direct imports from the new modules (no re-export shims in `subsonic.ts`). Pure code-move; contract test stays green. subsonic.ts: 1333 → 1078 LOC (−255).
217 lines
7.4 KiB
TypeScript
217 lines
7.4 KiB
TypeScript
import type { InternetRadioStation } from '../api/subsonicTypes';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { usePerfProbeFlags } from '../utils/perfFlags';
|
|
import {
|
|
guessAzuraCastApiUrl,
|
|
normaliseAzuraCastHomepageUrl,
|
|
fetchAzuraCastNowPlaying,
|
|
type AzuraCastNowPlaying,
|
|
type AzuraCastSong,
|
|
} from '../api/azuracast';
|
|
|
|
// ─── Public types ─────────────────────────────────────────────────────────────
|
|
|
|
export type RadioMetadataSource = 'azuracast' | 'icy' | 'none';
|
|
|
|
export interface RadioHistoryItem {
|
|
song: AzuraCastSong;
|
|
playedAt?: number; // unix timestamp
|
|
}
|
|
|
|
export interface RadioMetadata {
|
|
/** Metadata source that is currently active. */
|
|
source: RadioMetadataSource;
|
|
/** Station name (from ICY icy-name or AzuraCast station.name). */
|
|
stationName?: string;
|
|
/** Current track title (combined or individual fields). */
|
|
currentTitle?: string;
|
|
currentArtist?: string;
|
|
currentAlbum?: string;
|
|
currentArt?: string;
|
|
/** AzuraCast-only: seconds elapsed in current track. */
|
|
elapsed?: number;
|
|
/** AzuraCast-only: total duration of current track in seconds. */
|
|
duration?: number;
|
|
/** AzuraCast-only: number of current listeners. */
|
|
listeners?: number;
|
|
/** AzuraCast-only: last N played tracks. */
|
|
history: RadioHistoryItem[];
|
|
/** AzuraCast-only: next track queued. */
|
|
nextSong?: AzuraCastSong;
|
|
}
|
|
|
|
// ─── ICY metadata interface (matches Rust IcyMetadata struct) ─────────────────
|
|
|
|
interface IcyMetadataResult {
|
|
stream_title?: string;
|
|
icy_name?: string;
|
|
icy_genre?: string;
|
|
icy_url?: string;
|
|
icy_description?: string;
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function parseIcyStreamTitle(streamTitle: string): { artist?: string; title: string } {
|
|
const sep = streamTitle.indexOf(' - ');
|
|
if (sep !== -1) {
|
|
return { artist: streamTitle.slice(0, sep).trim(), title: streamTitle.slice(sep + 3).trim() };
|
|
}
|
|
return { title: streamTitle };
|
|
}
|
|
|
|
function nowPlayingToMetadata(np: AzuraCastNowPlaying): RadioMetadata {
|
|
const nowPlaying = np.now_playing;
|
|
const song = nowPlaying?.song;
|
|
return {
|
|
source: 'azuracast',
|
|
stationName: np.station?.name,
|
|
currentTitle: song?.title,
|
|
currentArtist: song?.artist,
|
|
currentAlbum: song?.album,
|
|
currentArt: song?.art,
|
|
elapsed: nowPlaying?.elapsed,
|
|
duration: nowPlaying?.duration,
|
|
listeners: np.listeners?.current,
|
|
history: (np.song_history ?? []).slice(0, 5).map(h => ({
|
|
song: h.song,
|
|
playedAt: h.played_at,
|
|
})),
|
|
nextSong: np.playing_next?.song ?? undefined,
|
|
};
|
|
}
|
|
|
|
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
|
|
|
const AZURACAST_POLL_MS = 15_000;
|
|
const ICY_POLL_MS = 30_000;
|
|
const EMPTY_METADATA: RadioMetadata = { source: 'none', history: [] };
|
|
|
|
export function useRadioMetadata(station: InternetRadioStation | null): RadioMetadata {
|
|
const perfFlags = usePerfProbeFlags();
|
|
const [metadata, setMetadata] = useState<RadioMetadata>(EMPTY_METADATA);
|
|
|
|
// Keep elapsed in sync while AzuraCast is active: advance 1 s/tick while playing.
|
|
const elapsedRef = useRef<number | null>(null);
|
|
const elapsedIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const stationRef = useRef<InternetRadioStation | null>(null);
|
|
|
|
// Store resolved AzuraCast API URL for the current station (or null).
|
|
const azuraCastUrlRef = useRef<string | null>(null);
|
|
|
|
// Stop the elapsed ticker.
|
|
function stopElapsedTick() {
|
|
if (elapsedIntervalRef.current) {
|
|
clearInterval(elapsedIntervalRef.current);
|
|
elapsedIntervalRef.current = null;
|
|
}
|
|
elapsedRef.current = null;
|
|
}
|
|
|
|
// Start a 1-second elapsed ticker that advances the stored elapsed value and
|
|
// updates the metadata state so the progress bar moves smoothly between polls.
|
|
function startElapsedTick(initial: number) {
|
|
stopElapsedTick();
|
|
elapsedRef.current = initial;
|
|
elapsedIntervalRef.current = setInterval(() => {
|
|
if (elapsedRef.current === null) return;
|
|
elapsedRef.current += 1;
|
|
setMetadata(prev =>
|
|
prev.source === 'azuracast'
|
|
? { ...prev, elapsed: elapsedRef.current! }
|
|
: prev
|
|
);
|
|
}, 1000);
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (perfFlags.disableBackgroundPolling) {
|
|
setMetadata(EMPTY_METADATA);
|
|
azuraCastUrlRef.current = null;
|
|
stopElapsedTick();
|
|
return;
|
|
}
|
|
if (!station) {
|
|
setMetadata(EMPTY_METADATA);
|
|
azuraCastUrlRef.current = null;
|
|
stopElapsedTick();
|
|
return;
|
|
}
|
|
|
|
stationRef.current = station;
|
|
setMetadata(EMPTY_METADATA);
|
|
azuraCastUrlRef.current = null;
|
|
stopElapsedTick();
|
|
|
|
let cancelled = false;
|
|
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
// Determine which AzuraCast API URL to try, in priority order:
|
|
// 1. Homepage URL if it matches the /api/nowplaying[/shortcode] pattern
|
|
// 2. Guessed URL from stream URL path (/listen/<shortcode>/…)
|
|
const candidateApiUrl =
|
|
(station.homepageUrl ? normaliseAzuraCastHomepageUrl(station.homepageUrl) : null) ??
|
|
guessAzuraCastApiUrl(station.streamUrl);
|
|
|
|
async function pollAzuraCast(apiUrl: string) {
|
|
if (cancelled) return;
|
|
const np = await fetchAzuraCastNowPlaying(apiUrl);
|
|
if (cancelled) return;
|
|
if (np) {
|
|
const m = nowPlayingToMetadata(np);
|
|
setMetadata(m);
|
|
startElapsedTick(m.elapsed ?? 0);
|
|
pollTimer = setTimeout(() => pollAzuraCast(apiUrl), AZURACAST_POLL_MS);
|
|
} else {
|
|
// AzuraCast check failed — fall back to ICY
|
|
azuraCastUrlRef.current = null;
|
|
pollIcy();
|
|
}
|
|
}
|
|
|
|
async function pollIcy() {
|
|
if (cancelled) return;
|
|
const currentStation = stationRef.current;
|
|
if (!currentStation) return;
|
|
try {
|
|
const result: IcyMetadataResult = await invoke('fetch_icy_metadata', { url: currentStation.streamUrl });
|
|
if (cancelled) return;
|
|
if (result.stream_title || result.icy_name) {
|
|
const parsed = result.stream_title ? parseIcyStreamTitle(result.stream_title) : null;
|
|
setMetadata({
|
|
source: 'icy',
|
|
stationName: result.icy_name,
|
|
currentTitle: parsed?.title,
|
|
currentArtist: parsed?.artist,
|
|
history: [],
|
|
});
|
|
}
|
|
} catch {
|
|
// ICY metadata not available — leave empty metadata
|
|
}
|
|
if (!cancelled) {
|
|
pollTimer = setTimeout(pollIcy, ICY_POLL_MS);
|
|
}
|
|
}
|
|
|
|
// Kick off detection and polling.
|
|
if (candidateApiUrl) {
|
|
// Try AzuraCast first; fall back to ICY inside pollAzuraCast if it fails.
|
|
azuraCastUrlRef.current = candidateApiUrl;
|
|
pollAzuraCast(candidateApiUrl);
|
|
} else {
|
|
pollIcy();
|
|
}
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (pollTimer) clearTimeout(pollTimer);
|
|
stopElapsedTick();
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [station?.id, station?.streamUrl, station?.homepageUrl, perfFlags.disableBackgroundPolling]);
|
|
|
|
return metadata;
|
|
}
|