fix(radio): preserve server ownership across flows

This commit is contained in:
cucadmuh
2026-07-19 04:08:48 +03:00
parent 55d0b93268
commit 011f2d9d46
30 changed files with 1476 additions and 217 deletions
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { getPlaybackProgressSnapshot } from '@/features/playback/store/playbackProgress';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
@@ -61,7 +62,7 @@ export function usePlayerSnapshotPublisher() {
};
const stableKey = JSON.stringify({
trackId: s.currentTrack?.id ?? null,
radioId: s.currentRadio?.id ?? null,
radioId: s.currentRadio ? ownedEntityKey(s.currentRadio) : null,
queueIndex: s.queueIndex,
queueLength: total,
isPlaying: s.isPlaying,
+40
View File
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({
clearServer: vi.fn(),
forgetServer: vi.fn(),
invalidateLegacy: vi.fn(),
}));
vi.mock('@/generated/bindings', () => ({
commands: { coverCacheClearServer: hoisted.clearServer },
}));
vi.mock('./diskSrcCache', () => ({ forgetDiskSrcForServer: hoisted.forgetServer }));
vi.mock('./imageCache', () => ({ invalidateCoverArt: hoisted.invalidateLegacy }));
vi.mock('./ref', () => ({
radioCoverRef: () => ({
cacheKind: 'album',
cacheEntityId: 'ra-shared',
fetchCoverArtId: 'ra-shared',
serverScope: { kind: 'server', serverId: 'srv-b', url: '', username: '', password: '' },
}),
}));
vi.mock('./storageKeys', () => ({ coverIndexKeyFromRef: () => 'b.test' }));
import { invalidateRadioCoverArtCache } from './radioCoverInvalidation';
describe('invalidateRadioCoverArtCache', () => {
beforeEach(() => {
hoisted.clearServer.mockReset().mockResolvedValue({ status: 'ok', data: null });
hoisted.forgetServer.mockReset();
hoisted.invalidateLegacy.mockReset().mockResolvedValue(undefined);
});
it('drops memory, legacy, and native cover entries for the station owner', async () => {
await invalidateRadioCoverArtCache({ id: 'shared', serverId: 'srv-b' });
expect(hoisted.forgetServer).toHaveBeenCalledWith('b.test');
expect(hoisted.invalidateLegacy).toHaveBeenCalledWith('ra-shared', 'srv-b');
expect(hoisted.clearServer).toHaveBeenCalledWith('b.test');
});
});
+18
View File
@@ -0,0 +1,18 @@
import { commands } from '@/generated/bindings';
import type { InternetRadioStation } from '@/lib/api/subsonicTypes';
import { forgetDiskSrcForServer } from './diskSrcCache';
import { invalidateCoverArt } from './imageCache';
import { radioCoverRef } from './ref';
import { coverIndexKeyFromRef } from './storageKeys';
/** Radio cover edits are rare; clear the owner bucket because native cache has no per-entity delete command. */
export async function invalidateRadioCoverArtCache(
station: Pick<InternetRadioStation, 'id' | 'serverId'>,
): Promise<void> {
const ref = radioCoverRef(station);
const serverIndexKey = coverIndexKeyFromRef(ref);
forgetDiskSrcForServer(serverIndexKey);
await invalidateCoverArt(ref.cacheEntityId, station.serverId);
const result = await commands.coverCacheClearServer(serverIndexKey);
if (result.status === 'error') throw new Error(result.error);
}
+10
View File
@@ -5,10 +5,20 @@ import {
albumCoverRefForSong,
albumHasDistinctDiscCovers,
rememberAlbumDistinctDiscCovers,
radioCoverRef,
resolveAlbumCoverCacheEntityId,
resolveDistinctDiscCoversForAlbum,
} from './ref';
describe('radioCoverRef', () => {
it('keeps duplicate radio ids in the owning server cover bucket', () => {
const ref = radioCoverRef({ id: 'shared', serverId: 'srv-b' });
expect(ref.cacheEntityId).toBe('ra-shared');
expect(ref.fetchCoverArtId).toBe('ra-shared');
expect(ref.serverScope).toMatchObject({ kind: 'server', serverId: 'srv-b' });
});
});
describe('resolveAlbumCoverCacheEntityId', () => {
it('uses album id when fetch matches or is empty', () => {
expect(resolveAlbumCoverCacheEntityId('al-1', 'al-1')).toBe('al-1');
+13 -2
View File
@@ -1,7 +1,8 @@
import { getPlaybackServerId } from '@/features/playback/utils/playback/playbackServer';
import { useAuthStore } from '../store/authStore';
import { coverServerScopeForServerId } from './serverScope';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { coverServerScopeForOwnerServerId, coverServerScopeForServerId } from './serverScope';
import type { InternetRadioStation, SubsonicSong } from '@/lib/api/subsonicTypes';
import { coverArtIdFromRadio } from './ids';
import type { CoverArtId, CoverArtRef, CoverCacheKind, CoverServerScope } from './types';
import {
albumHasDistinctDiscCovers,
@@ -94,6 +95,16 @@ export function albumCoverRef(
return coverEntryToRef(entry, serverScope);
}
export function radioCoverRef(
station: Pick<InternetRadioStation, 'id' | 'serverId'>,
): CoverArtRef {
const coverArtId = coverArtIdFromRadio(station.id);
const serverScope = station.serverId
? coverServerScopeForOwnerServerId(station.serverId)
: { kind: 'active' as const };
return albumCoverRef(coverArtId, coverArtId, serverScope);
}
export function albumCoverRefForSong(
song: Pick<SubsonicSong, 'albumId' | 'coverArt' | 'id' | 'discNumber'>,
distinctDiscCovers?: boolean,
@@ -1,11 +1,13 @@
import React, { useRef, useState } from 'react';
import React, { useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Cast, ChevronLeft, ChevronRight, Heart, X } from 'lucide-react';
import type { InternetRadioStation } from '@/lib/api/subsonicTypes';
import { CoverArtImage } from '@/cover/CoverArtImage';
import { albumCoverRef } from '@/cover/ref';
import { coverArtIdFromRadio } from '@/cover/ids';
import { radioCoverRef } from '@/cover/ref';
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '@/cover/layoutSizes';
import { radioStationKey, sameRadioStation } from '@/features/radio';
import { useAuthStore } from '@/store/authStore';
import { serverListDisplayLabel } from '@/lib/server/serverDisplayName';
interface RadioStationRowProps {
title: string;
@@ -13,13 +15,18 @@ interface RadioStationRowProps {
currentRadio: InternetRadioStation | null;
isPlaying: boolean;
onPlay: (s: InternetRadioStation) => void;
onUnfavorite: (id: string) => void;
onUnfavorite: (station: InternetRadioStation) => void;
}
export function RadioStationRow({ title, stations, currentRadio, isPlaying, onPlay, onUnfavorite }: RadioStationRowProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const servers = useAuthStore(s => s.servers);
const serverLabelById = useMemo(() => new Map(
servers.map(server => [server.id, serverListDisplayLabel(server, servers)]),
), [servers]);
const showServerLabels = new Set(stations.map(station => station.serverId).filter(Boolean)).size > 1;
const handleScroll = () => {
if (!scrollRef.current) return;
@@ -50,12 +57,13 @@ export function RadioStationRow({ title, stations, currentRadio, isPlaying, onPl
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{stations.map(s => (
<RadioFavCard
key={s.id}
key={radioStationKey(s)}
station={s}
isActive={currentRadio?.id === s.id}
isActive={sameRadioStation(currentRadio, s)}
isPlaying={isPlaying}
serverLabel={showServerLabels && s.serverId ? serverLabelById.get(s.serverId) : undefined}
onPlay={() => onPlay(s)}
onUnfavorite={() => onUnfavorite(s.id)}
onUnfavorite={() => onUnfavorite(s)}
/>
))}
</div>
@@ -68,18 +76,19 @@ interface RadioFavCardProps {
station: InternetRadioStation;
isActive: boolean;
isPlaying: boolean;
serverLabel?: string;
onPlay: () => void;
onUnfavorite: () => void;
}
function RadioFavCard({ station: s, isActive, isPlaying, onPlay, onUnfavorite }: RadioFavCardProps) {
function RadioFavCard({ station: s, isActive, isPlaying, serverLabel, onPlay, onUnfavorite }: RadioFavCardProps) {
const { t } = useTranslation();
return (
<div className={`album-card${isActive ? ' radio-card-active' : ''}`}>
<div className="album-card-cover">
{s.coverArt ? (
<CoverArtImage
coverRef={albumCoverRef(coverArtIdFromRadio(s.id), coverArtIdFromRadio(s.id))}
coverRef={radioCoverRef(s)}
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
surface="dense"
alt={s.name}
@@ -96,7 +105,11 @@ function RadioFavCard({ station: s, isActive, isPlaying, onPlay, onUnfavorite }:
</div>
)}
<div className="album-card-play-overlay">
<button className="album-card-details-btn" onClick={onPlay}>
<button
className="album-card-details-btn"
onClick={onPlay}
aria-label={isActive && isPlaying ? t('radio.stopStation') : t('radio.playStation')}
>
{isActive && isPlaying ? <X size={15} /> : <Cast size={14} />}
</button>
</div>
@@ -104,10 +117,12 @@ function RadioFavCard({ station: s, isActive, isPlaying, onPlay, onUnfavorite }:
<div className="album-card-info">
<div className="album-card-title">{s.name}</div>
<div className="album-card-artist" style={{ display: 'flex', alignItems: 'center' }}>
{serverLabel && <span style={{ marginRight: 6 }}>{serverLabel}</span>}
<button
className="radio-favorite-btn active"
style={{ background: 'none', border: 'none', padding: '2px', cursor: 'pointer', display: 'flex' }}
onClick={onUnfavorite}
aria-label={t('radio.unfavorite')}
data-tooltip={t('radio.unfavorite')}
>
<Heart size={12} fill="currentColor" />
@@ -0,0 +1,96 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { InternetRadioStation } from '@/lib/api/subsonicTypes';
const hoisted = vi.hoisted(() => ({
getRadio: vi.fn(),
getStarred: vi.fn(),
}));
vi.mock('@/lib/api/subsonicRadio', () => ({
getInternetRadioStationsForServersSettled: hoisted.getRadio,
}));
vi.mock('@/lib/api/subsonicStarRating', () => ({ getStarred: hoisted.getStarred }));
vi.mock('@/features/playback/store/playerStore', () => ({
usePlayerStore: Object.assign(
(selector: (state: { starredOverrides: Record<string, boolean> }) => unknown) => (
selector({ starredOverrides: {} })
),
{
getState: () => ({ starredOverrides: {} }),
setState: vi.fn(),
},
),
}));
vi.mock('@/lib/hooks/useConnectionStatus', () => ({
useConnectionStatus: () => ({ status: 'connected' }),
}));
vi.mock('@/lib/network/activeServerReachability', () => ({
isActiveServerReachable: () => true,
}));
vi.mock('@/features/offline', () => ({
useOfflineBrowseContext: () => ({ active: false }),
useOfflineBrowseReloadToken: () => 0,
loadStarredFromAllLibraryIndexes: vi.fn(async () => ({ albums: [], artists: [], songs: [] })),
loadStarredFromAllServersOnline: vi.fn(async () => ({ albums: [], artists: [], songs: [] })),
}));
vi.mock('@/lib/library/favoritesBrowseDebug', () => ({
beginFavoritesBrowseTrace: vi.fn(),
emitFavoritesBrowseDebug: vi.fn(),
favoritesBrowseTimed: (_name: string, run: () => Promise<unknown>) => run(),
}));
import { useFavoritesData } from './useFavoritesData';
import { resetAuthStore } from '@/test/helpers/storeReset';
import { useAuthStore } from '@/store/authStore';
const STATION: InternetRadioStation = {
id: 'shared',
serverId: 'srv-a',
name: 'Alpha Radio',
streamUrl: 'https://a.test/live',
};
describe('useFavoritesData radio ownership', () => {
beforeEach(() => {
resetAuthStore();
localStorage.clear();
hoisted.getRadio.mockReset();
hoisted.getStarred.mockReset().mockResolvedValue({ albums: [], artists: [], songs: [] });
useAuthStore.setState({
isLoggedIn: true,
servers: [{
id: 'srv-a',
name: 'Home',
url: 'https://a.test',
username: 'a',
password: 'p',
}],
activeServerId: 'srv-a',
libraryBrowseServerIds: ['srv-a'],
favoritesOfflineEnabled: false,
});
});
it('does not restore an unfavorited station from an older refresh', async () => {
localStorage.setItem('psysonic_radio_favorites', JSON.stringify([
'shared',
'srv-a:shared',
]));
let resolveRadio: ((value: {
stations: InternetRadioStation[];
failedServerIds: string[];
}) => void) | undefined;
hoisted.getRadio.mockImplementation(() => new Promise(resolve => {
resolveRadio = resolve;
}));
const { result } = renderHook(() => useFavoritesData());
await waitFor(() => expect(hoisted.getRadio).toHaveBeenCalled());
act(() => result.current.unfavoriteStation(STATION));
act(() => resolveRadio?.({ stations: [STATION], failedServerIds: [] }));
await waitFor(() => expect(result.current.radioStations).toEqual([]));
expect(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]')).toEqual([]);
});
});
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { getInternetRadioStations } from '@/lib/api/subsonicRadio';
import { useEffect, useMemo, useRef, useState } from 'react';
import { getInternetRadioStationsForServersSettled } from '@/lib/api/subsonicRadio';
import { getStarred } from '@/lib/api/subsonicStarRating';
import type {
InternetRadioStation, SubsonicAlbum, SubsonicArtist, SubsonicSong,
@@ -21,6 +21,12 @@ import {
favoritesBrowseTimed,
} from '@/lib/library/favoritesBrowseDebug';
import { ownedOverrideValue } from '@/lib/util/ownedEntityKey';
import { deriveEffectiveLibraryBrowseServerIds } from '@/lib/library/libraryBrowseScope';
import { useUnavailableServerIds } from '@/lib/network/serverReachability';
import {
migrateRadioStationKeys,
radioStationKey,
} from '@/features/radio';
export interface FavoritesDataResult {
albums: SubsonicAlbum[];
@@ -31,7 +37,7 @@ export interface FavoritesDataResult {
setRadioStations: React.Dispatch<React.SetStateAction<InternetRadioStation[]>>;
loading: boolean;
topFavoriteArtists: TopFavoriteArtist[];
unfavoriteStation: (id: string) => void;
unfavoriteStation: (station: InternetRadioStation) => void;
}
function topArtistKey(song: SubsonicSong): string {
@@ -46,13 +52,18 @@ export function useFavoritesData(): FavoritesDataResult {
const [songs, setSongs] = useState<SubsonicSong[]>([]);
const [radioStations, setRadioStations] = useState<InternetRadioStation[]>([]);
const [loading, setLoading] = useState(true);
const radioMutationGenerationRef = useRef(0);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const activeServerId = useAuthStore(s => s.activeServerId);
const libraryBrowseServerIds = useAuthStore(s => s.libraryBrowseServerIds);
const libraryBrowseScopeVersion = useAuthStore(s => s.libraryBrowseScopeVersion);
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
const servers = useAuthStore(s => s.servers);
const { status: connStatus } = useConnectionStatus();
const offlineBrowseActive = useOfflineBrowseContext().active;
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
const unavailableServerIds = useUnavailableServerIds();
const starredOverrides = usePlayerStore(s => s.starredOverrides);
useEffect(() => {
@@ -75,18 +86,38 @@ export function useFavoritesData(): FavoritesDataResult {
};
const loadRadioFavorites = async () => {
if (!isActiveServerReachable()) return;
try {
const mutationGeneration = radioMutationGenerationRef.current;
const favIds = new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]'));
if (favIds.size === 0) return;
const all = await favoritesBrowseTimed('radio_stations', () => getInternetRadioStations(), {
const serverIds = deriveEffectiveLibraryBrowseServerIds({
servers,
activeServerId,
libraryBrowseServerIds,
}, unavailableServerIds);
const result = await favoritesBrowseTimed('radio_stations', () => (
getInternetRadioStationsForServersSettled(serverIds)
), {
favoriteStationCount: favIds.size,
});
if (!cancelled) {
const favoriteStations = all.filter(s => favIds.has(s.id));
setRadioStations(favoriteStations);
if (!cancelled && mutationGeneration === radioMutationGenerationRef.current) {
const failed = new Set(result.failedServerIds);
setRadioStations(previous => {
const available = serverIds.flatMap(serverId => failed.has(serverId)
? previous.filter(station => station.serverId === serverId)
: result.stations.filter(station => station.serverId === serverId));
const migrated = new Set(migrateRadioStationKeys(
[...favIds],
available,
activeServerId,
));
localStorage.setItem('psysonic_radio_favorites', JSON.stringify([...migrated]));
return available.filter(station => migrated.has(radioStationKey(station)));
});
emitFavoritesBrowseDebug('radio_favorites_applied', {
stationCount: favoriteStations.length,
stationCount: result.stations.filter(station => (
favIds.has(radioStationKey(station)) || favIds.has(station.id)
)).length,
});
}
} catch { /* ignore */ }
@@ -138,7 +169,18 @@ export function useFavoritesData(): FavoritesDataResult {
void loadAll();
return () => { cancelled = true; };
}, [musicLibraryFilterVersion, connStatus, favoritesOfflineEnabled, offlineBrowseActive, offlineBrowseReloadTs, servers]);
}, [
musicLibraryFilterVersion,
libraryBrowseScopeVersion,
connStatus,
favoritesOfflineEnabled,
offlineBrowseActive,
offlineBrowseReloadTs,
activeServerId,
libraryBrowseServerIds,
servers,
unavailableServerIds,
]);
const topFavoriteArtists = useMemo<TopFavoriteArtist[]>(() => {
const counts = new Map<string, TopFavoriteArtist>();
@@ -165,11 +207,14 @@ export function useFavoritesData(): FavoritesDataResult {
.slice(0, 12);
}, [songs, starredOverrides]);
function unfavoriteStation(id: string) {
setRadioStations(prev => prev.filter(s => s.id !== id));
function unfavoriteStation(station: InternetRadioStation) {
radioMutationGenerationRef.current += 1;
const key = radioStationKey(station);
setRadioStations(prev => prev.filter(candidate => radioStationKey(candidate) !== key));
try {
const next = new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]'));
next.delete(id);
next.delete(key);
next.delete(station.id);
localStorage.setItem('psysonic_radio_favorites', JSON.stringify([...next]));
} catch { /* ignore */ }
}
+2 -1
View File
@@ -25,6 +25,7 @@ import { usePsyLabDebugTraces } from '@/lib/perf/psyLabDebugTraces';
import { getLibraryBrowseScope } from '@/lib/library/libraryBrowseScope';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { sameRadioStation } from '@/features/radio';
const FAV_COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
@@ -215,7 +216,7 @@ export default function Favorites() {
currentRadio={currentRadio}
isPlaying={isPlaying}
onPlay={s => {
if (currentRadio?.id === s.id && isPlaying) stop();
if (sameRadioStation(currentRadio, s) && isPlaying) stop();
else playRadio(s);
}}
onUnfavorite={unfavoriteStation}
@@ -1,12 +1,10 @@
import { useEffect } from 'react';
import { coverCacheEnsure, coverCachePeekBatch } from '@/lib/api/coverCache';
import { albumCoverRef } from '@/cover/ref';
import { resolvePlaybackCoverScope } from '@/cover/ref';
import { radioCoverRef, resolvePlaybackCoverScope } from '@/cover/ref';
import { resolveTrackCoverRefFromLibrary } from '@/cover/resolveEntryLibrary';
import { getDiskSrc, rememberDiskSrc } from '@/cover/diskSrcCache';
import { coverStorageKeyFromRef } from '@/cover/storageKeys';
import { resolveCoverDisplayTier } from '@/cover/tiers';
import { coverArtIdFromRadio } from '@/cover/ids';
import type { CoverArtRef } from '@/cover/types';
import { prewarmNowPlayingFetchers } from '@/features/nowPlaying/hooks/useNowPlayingFetchers';
import { useAuthStore } from '@/store/authStore';
@@ -99,8 +97,7 @@ export function useNowPlayingPrewarm(): void {
]);
useEffect(() => {
if (!currentRadio?.coverArt || !activeServerId) return;
const radioCoverArtId = coverArtIdFromRadio(currentRadio.id);
void prewarmCoverRef(albumCoverRef(radioCoverArtId, radioCoverArtId, { kind: 'active' }));
}, [currentRadio?.id, currentRadio?.coverArt, activeServerId]);
if (!currentRadio?.coverArt || (!currentRadio.serverId && !activeServerId)) return;
void prewarmCoverRef(radioCoverRef(currentRadio));
}, [currentRadio, activeServerId]);
}
+2 -4
View File
@@ -1,7 +1,6 @@
import { useCoverArt } from '@/cover/useCoverArt';
import { albumCoverRef } from '@/cover/ref';
import { radioCoverRef } from '@/cover/ref';
import { usePlaybackTrackCoverRef } from '@/cover/useLibraryCoverRef';
import { coverArtIdFromRadio } from '@/cover/ids';
import type { SubsonicArtistInfo, SubsonicSong } from '@/lib/api/subsonicTypes';
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { usePlaybackLibraryNavigate } from '@/features/playback/hooks/usePlaybackLibraryNavigate';
@@ -109,9 +108,8 @@ export default function NowPlaying() {
const playbackCoverRef = usePlaybackTrackCoverRef(currentTrack ?? undefined);
const radioCoverArtId = currentRadio?.coverArt ? coverArtIdFromRadio(currentRadio.id) : undefined;
const radioCover = useCoverArt(
radioCoverArtId ? albumCoverRef(radioCoverArtId, radioCoverArtId) : null,
currentRadio?.coverArt ? radioCoverRef(currentRadio) : null,
800,
{ surface: 'sparse' },
);
@@ -7,7 +7,7 @@ import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
import type { RadioMetadata } from '@/features/radio';
import type { PreviewingTrack } from '@/features/playback/store/previewStore';
import { CoverArtImage } from '@/cover/CoverArtImage';
import { albumCoverRef } from '@/cover/ref';
import { radioCoverRef } from '@/cover/ref';
import { useAlbumCoverRef } from '@/cover/useLibraryCoverRef';
import { usePlaybackTrackCoverRef } from '@/cover/useLibraryCoverRef';
import MarqueeText from '@/ui/MarqueeText';
@@ -94,7 +94,7 @@ export function PlayerTrackInfo({
radioCoverArtId && currentRadio ? (
<CoverArtImage
className="player-album-art"
coverRef={albumCoverRef(radioCoverArtId, radioCoverArtId)}
coverRef={radioCoverRef(currentRadio)}
displayCssPx={128}
surface="sparse"
alt={currentRadio?.name ?? ''}
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({
mprisSetMetadata: vi.fn(() => Promise.resolve()),
mprisSetPlayback: vi.fn(() => Promise.resolve()),
}));
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(() => Promise.resolve()) }));
vi.mock('@/lib/api/mpris', () => ({
mprisSetMetadata: hoisted.mprisSetMetadata,
mprisSetPlayback: hoisted.mprisSetPlayback,
}));
vi.mock('@/cover/resolveEntryLibrary', () => ({
resolveTrackCoverRefFromLibrary: vi.fn(() => Promise.resolve(null)),
}));
vi.mock('@/cover/integrations/mpris', () => ({
coverArtUrlForMpris: vi.fn(() => Promise.resolve('')),
}));
vi.mock('@/features/playback/store/playbackProgress', () => ({
getPlaybackProgressSnapshot: () => ({ currentTime: 0 }),
subscribePlaybackProgress: () => () => {},
}));
import { setupMprisSync } from './mprisSync';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { resetPlayerStore } from '@/test/helpers/storeReset';
describe('setupMprisSync radio ownership', () => {
beforeEach(() => {
resetPlayerStore();
hoisted.mprisSetMetadata.mockClear();
hoisted.mprisSetPlayback.mockClear();
});
it('pushes metadata when radio ownership changes but the raw id is the same', () => {
const cleanup = setupMprisSync();
usePlayerStore.setState({
currentRadio: {
id: 'shared',
serverId: 'srv-a',
name: 'Alpha Radio',
streamUrl: 'https://a.test/live',
},
isPlaying: true,
});
usePlayerStore.setState({
currentRadio: {
id: 'shared',
serverId: 'srv-b',
name: 'Beta Radio',
streamUrl: 'https://b.test/live',
},
isPlaying: true,
});
expect(hoisted.mprisSetMetadata).toHaveBeenNthCalledWith(1, expect.objectContaining({
title: 'Alpha Radio',
}));
expect(hoisted.mprisSetMetadata).toHaveBeenNthCalledWith(2, expect.objectContaining({
title: 'Beta Radio',
}));
cleanup();
});
});
@@ -5,6 +5,7 @@ import { resolveTrackCoverRefFromLibrary } from '@/cover/resolveEntryLibrary';
import { coverArtUrlForMpris } from '@/cover/integrations/mpris';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '@/features/playback/store/playbackProgress';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
/**
* MPRIS / OS media-controls sync. Whenever the current track or playback state
@@ -13,7 +14,7 @@ import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '@/featur
*/
export function setupMprisSync(): () => void {
let prevTrackId: string | null = null;
let prevRadioId: string | null = null;
let prevRadioKey: string | null = null;
let prevIsPlaying: boolean | null = null;
let lastMprisPositionUpdate = 0;
@@ -23,7 +24,7 @@ export function setupMprisSync(): () => void {
// Update metadata when track changes
if (currentTrack && currentTrack.id !== prevTrackId) {
prevTrackId = currentTrack.id;
prevRadioId = null;
prevRadioKey = null;
const title = currentTrack.title;
const artist = currentTrack.artist;
const album = currentTrack.album;
@@ -62,8 +63,9 @@ export function setupMprisSync(): () => void {
// Update metadata when a radio station starts (initial push — station name as title).
// ICY StreamTitle updates are forwarded by the radio:metadata listener below.
if (currentRadio && currentRadio.id !== prevRadioId) {
prevRadioId = currentRadio.id;
const currentRadioKey = currentRadio ? ownedEntityKey(currentRadio) : null;
if (currentRadio && currentRadioKey !== prevRadioKey) {
prevRadioKey = currentRadioKey;
prevTrackId = null;
mprisSetMetadata({
title: currentRadio.name,
@@ -0,0 +1,90 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { InternetRadioStation } from '@/lib/api/subsonicTypes';
const mocks = vi.hoisted(() => ({
resolveStreamUrl: vi.fn(),
playRadioStream: vi.fn(),
}));
vi.mock('@/generated/bindings', () => ({
commands: { resolveStreamUrl: mocks.resolveStreamUrl },
}));
vi.mock('@/features/playback/store/radioPlayer', () => ({
clearRadioReconnectTimer: vi.fn(),
playRadioStream: mocks.playRadioStream,
prepareRadioPlaybackFromUserGesture: vi.fn(),
setRadioVolume: vi.fn(),
}));
vi.mock('@/lib/api/audio', () => ({
audioSeek: vi.fn(),
audioSetVolume: vi.fn(),
audioStop: vi.fn(() => Promise.resolve()),
}));
import { _resetEngineStateForTest } from './engineState';
import { createMiscActions } from './miscActions';
import type { PlayerState } from './playerStoreTypes';
const STATION_A: InternetRadioStation = {
id: 'shared',
serverId: 'srv-a',
name: 'Alpha',
streamUrl: 'https://a.test/listen.m3u',
};
const STATION_B: InternetRadioStation = {
id: 'shared',
serverId: 'srv-b',
name: 'Beta',
streamUrl: 'https://b.test/live',
};
function createHarness() {
let state = { volume: 1 } as PlayerState;
const set = (partial: Partial<PlayerState> | ((current: PlayerState) => Partial<PlayerState>)) => {
state = { ...state, ...(typeof partial === 'function' ? partial(state) : partial) };
};
const get = () => state;
return { actions: createMiscActions(set, get), getState: get };
}
describe('playRadio stale request protection', () => {
beforeEach(() => {
_resetEngineStateForTest();
mocks.resolveStreamUrl.mockReset();
mocks.playRadioStream.mockReset().mockResolvedValue(undefined);
});
it('does not start a station whose playlist resolution completed after a newer request', async () => {
let resolveFirst: ((url: string) => void) | undefined;
mocks.resolveStreamUrl
.mockImplementationOnce(() => new Promise<string>(resolve => { resolveFirst = resolve; }))
.mockResolvedValueOnce(STATION_B.streamUrl);
const { actions, getState } = createHarness();
const first = actions.playRadio(STATION_A);
await actions.playRadio(STATION_B);
resolveFirst?.('https://a.test/live');
await first;
expect(mocks.playRadioStream).toHaveBeenCalledTimes(1);
expect(mocks.playRadioStream).toHaveBeenCalledWith(STATION_B.streamUrl, 1);
expect(getState().currentRadio).toEqual(STATION_B);
});
it('does not publish stale state when an older play call settles last', async () => {
let finishFirstPlay: (() => void) | undefined;
mocks.resolveStreamUrl.mockImplementation((url: string) => Promise.resolve(url));
mocks.playRadioStream
.mockImplementationOnce(() => new Promise<void>(resolve => { finishFirstPlay = resolve; }))
.mockResolvedValueOnce(undefined);
const { actions, getState } = createHarness();
const first = actions.playRadio(STATION_A);
await vi.waitFor(() => expect(mocks.playRadioStream).toHaveBeenCalledTimes(1));
await actions.playRadio(STATION_B);
finishFirstPlay?.();
await first;
expect(getState().currentRadio).toEqual(STATION_B);
});
});
+5 -1
View File
@@ -6,6 +6,7 @@ import { showToast } from '@/lib/dom/toast';
import { useAuthStore } from '@/store/authStore';
import {
bumpPlayGeneration,
getPlayGeneration,
setIsAudioPaused,
} from '@/features/playback/store/engineState';
import { clearPreloadingIds } from '@/features/playback/store/gaplessPreloadState';
@@ -70,7 +71,7 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
playRadio: async (station) => {
const { volume } = get();
prepareRadioPlaybackFromUserGesture();
bumpPlayGeneration();
const generation = bumpPlayGeneration();
clearAllPlaybackScheduleTimers();
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
setIsAudioPaused(false);
@@ -84,16 +85,19 @@ export function createMiscActions(set: SetState, get: GetState): Pick<
// to HTML5 <audio> — the browser cannot play playlist files directly.
const streamUrl = await commands.resolveStreamUrl(station.streamUrl)
.catch(() => station.streamUrl);
if (getPlayGeneration() !== generation) return;
const { replayGainFallbackDb } = useAuthStore.getState();
const fallbackFactor = replayGainFallbackDb !== 0 ? Math.pow(10, replayGainFallbackDb / 20) : 1;
try {
await playRadioStream(streamUrl, Math.min(1, volume * fallbackFactor));
} catch (err: unknown) {
if (getPlayGeneration() !== generation) return;
console.error('[psysonic] radio HTML5 play failed:', err);
showToast('Radio stream error', 3000, 'error');
set({ isPlaying: false, currentRadio: null });
return;
}
if (getPlayGeneration() !== generation) return;
set({
currentRadio: station,
currentTrack: null,
+18 -11
View File
@@ -5,9 +5,9 @@ import { open } from '@tauri-apps/plugin-shell';
import type { InternetRadioStation } from '@/lib/api/subsonicTypes';
import { useDragDrop, useDragSource } from '@/lib/dnd/DragDropContext';
import { CoverArtImage } from '@/cover/CoverArtImage';
import { albumCoverRef } from '@/cover/ref';
import { coverArtIdFromRadio } from '@/cover/ids';
import { radioCoverRef } from '@/cover/ref';
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '@/cover/layoutSizes';
import { radioStationKey } from '@/features/radio/utils/radioStationIdentity';
interface RadioCardProps {
s: InternetRadioStation;
@@ -18,6 +18,7 @@ interface RadioCardProps {
isManual: boolean;
/** Navidrome ≥ 0.62 only lets admins manage stations — hides edit/delete. */
canManage: boolean;
serverLabel?: string;
dropIndicator: 'before' | 'after' | null;
onPlay: (e: React.MouseEvent) => void;
onDelete: (e: React.MouseEvent) => void;
@@ -30,7 +31,7 @@ interface RadioCardProps {
}
export default function RadioCard({
s, isActive, isPlaying, deleteConfirmId, isFavorite, isManual, canManage, dropIndicator,
s, isActive, isPlaying, deleteConfirmId, isFavorite, isManual, canManage, serverLabel, dropIndicator,
onPlay, onDelete, onEdit, onFavoriteToggle, onDragEnter, onDragLeave,
onDropOnto, onCardMouseLeave,
}: RadioCardProps) {
@@ -38,12 +39,13 @@ export default function RadioCard({
const cardRef = useRef<HTMLDivElement>(null);
const lastSideRef = useRef<'before' | 'after'>('after');
const { isDragging, payload } = useDragDrop();
const stationKey = radioStationKey(s);
const isBeingDragged = isDragging && !!payload && (() => {
try { return JSON.parse(payload.data).id === s.id; } catch { return false; }
try { return JSON.parse(payload.data).id === stationKey; } catch { return false; }
})();
const dragHandlers = useDragSource(() => ({
data: JSON.stringify({ type: 'radio', id: s.id }),
data: JSON.stringify({ type: 'radio', id: stationKey }),
label: s.name,
}));
@@ -61,11 +63,11 @@ export default function RadioCard({
if (!el) return;
const handler = (e: Event) => {
const data = JSON.parse((e as CustomEvent).detail?.data ?? '{}');
if (data.type === 'radio' && data.id !== s.id) onDropOnto(data.id, lastSideRef.current);
if (data.type === 'radio' && data.id !== stationKey) onDropOnto(data.id, lastSideRef.current);
};
el.addEventListener('psy-drop', handler);
return () => el.removeEventListener('psy-drop', handler);
}, [isManual, s.id, onDropOnto]);
}, [isManual, stationKey, onDropOnto]);
return (
<div
@@ -90,7 +92,7 @@ export default function RadioCard({
<div className="album-card-cover">
{s.coverArt ? (
<CoverArtImage
coverRef={albumCoverRef(coverArtIdFromRadio(s.id), coverArtIdFromRadio(s.id))}
coverRef={radioCoverRef(s)}
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
surface="dense"
alt={s.name}
@@ -112,6 +114,7 @@ export default function RadioCard({
<button
className="album-card-details-btn"
onClick={onPlay}
aria-label={isActive && isPlaying ? t('radio.stopStation') : t('radio.playStation')}
data-tooltip={isActive && isPlaying ? t('radio.stopStation') : t('radio.playStation')}
data-tooltip-pos="bottom"
>
@@ -122,12 +125,13 @@ export default function RadioCard({
{canManage && (
<div className="playlist-card-actions">
<button
className={`playlist-card-action playlist-card-action--delete ${deleteConfirmId === s.id ? 'playlist-card-action--delete-confirm' : ''}`}
className={`playlist-card-action playlist-card-action--delete ${deleteConfirmId === stationKey ? 'playlist-card-action--delete-confirm' : ''}`}
onClick={onDelete}
data-tooltip={deleteConfirmId === s.id ? t('radio.confirmDelete') : t('radio.deleteStation')}
aria-label={deleteConfirmId === stationKey ? t('radio.confirmDelete') : t('radio.deleteStation')}
data-tooltip={deleteConfirmId === stationKey ? t('radio.confirmDelete') : t('radio.deleteStation')}
data-tooltip-pos="bottom"
>
{deleteConfirmId === s.id ? <Trash2 size={12} /> : <X size={12} />}
{deleteConfirmId === stationKey ? <Trash2 size={12} /> : <X size={12} />}
</button>
</div>
)}
@@ -142,9 +146,11 @@ export default function RadioCard({
{t('radio.editStation')}
</button>
)}
{serverLabel && <span>{serverLabel}</span>}
<button
className={`player-btn player-btn-sm radio-favorite-btn${isFavorite ? ' active' : ''}`}
onClick={e => { e.stopPropagation(); onFavoriteToggle(); }}
aria-label={t(isFavorite ? 'radio.unfavorite' : 'radio.favorite')}
data-tooltip={t(isFavorite ? 'radio.unfavorite' : 'radio.favorite')}
>
<Heart size={11} fill={isFavorite ? 'currentColor' : 'none'} />
@@ -154,6 +160,7 @@ export default function RadioCard({
className="player-btn player-btn-sm"
style={{ opacity: 0.6 }}
onClick={() => open(s.homepageUrl!)}
aria-label={t('radio.openHomepage')}
data-tooltip={t('radio.openHomepage')}
>
<Globe size={11} />
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, waitFor } from '@testing-library/react';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
const mocks = vi.hoisted(() => ({
createForServer: vi.fn(),
fetchUrlBytes: vi.fn(),
getForServer: vi.fn(),
getTop: vi.fn(),
search: vi.fn(),
uploadBytesForServer: vi.fn(),
}));
vi.mock('@/lib/api/subsonicRadio', () => ({
createInternetRadioStationForServer: mocks.createForServer,
fetchUrlBytes: mocks.fetchUrlBytes,
getInternetRadioStationsForServer: mocks.getForServer,
getTopRadioStations: mocks.getTop,
searchRadioBrowser: mocks.search,
uploadRadioCoverArtBytesForServer: mocks.uploadBytesForServer,
}));
import RadioDirectoryModal from './RadioDirectoryModal';
describe('RadioDirectoryModal owner-scoped creation', () => {
beforeEach(() => {
Object.values(mocks).forEach(mock => mock.mockReset());
mocks.getTop.mockResolvedValue([{
stationuuid: 'directory-id',
name: 'Directory Station',
url: 'https://shared.test/live',
favicon: 'https://images.test/directory.png',
tags: '',
}]);
mocks.createForServer.mockResolvedValue(undefined);
mocks.getForServer.mockResolvedValue([
{
id: 'existing-id',
serverId: 'srv-owner',
name: 'Existing Station',
streamUrl: 'https://shared.test/live',
},
{
id: 'created-id',
serverId: 'srv-owner',
name: 'Directory Station',
streamUrl: 'https://shared.test/live',
},
]);
mocks.fetchUrlBytes.mockResolvedValue([[1, 2, 3], 'image/png']);
mocks.uploadBytesForServer.mockResolvedValue(undefined);
});
it('uploads the directory favicon to the station matching both name and stream URL', async () => {
const onAdded = vi.fn();
const view = renderWithProviders(
<RadioDirectoryModal
targetServerId="srv-owner"
onMutationStart={vi.fn()}
onClose={vi.fn()}
onAdded={onAdded}
/>,
);
fireEvent.click(await view.findByText('Directory Station'));
await waitFor(() => expect(mocks.uploadBytesForServer).toHaveBeenCalledWith(
'srv-owner',
'created-id',
[1, 2, 3],
'image/png',
));
expect(mocks.uploadBytesForServer).not.toHaveBeenCalledWith(
'srv-owner',
'existing-id',
expect.anything(),
expect.anything(),
);
expect(onAdded).toHaveBeenCalledOnce();
});
});
@@ -3,8 +3,8 @@ import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Cast, Check, Loader2, Plus, X } from 'lucide-react';
import {
createInternetRadioStation, fetchUrlBytes, getInternetRadioStations,
getTopRadioStations, searchRadioBrowser, uploadRadioCoverArtBytes,
createInternetRadioStationForServer, fetchUrlBytes, getInternetRadioStationsForServer,
getTopRadioStations, searchRadioBrowser, uploadRadioCoverArtBytesForServer,
} from '@/lib/api/subsonicRadio';
import {
type InternetRadioStation, type RadioBrowserStation, RADIO_PAGE_SIZE,
@@ -12,11 +12,18 @@ import {
import { showToast } from '@/lib/dom/toast';
interface RadioDirectoryModalProps {
targetServerId: string;
onMutationStart: () => void;
onClose: () => void;
onAdded: () => void;
}
export default function RadioDirectoryModal({ onClose, onAdded }: RadioDirectoryModalProps) {
export default function RadioDirectoryModal({
targetServerId,
onMutationStart,
onClose,
onAdded,
}: RadioDirectoryModalProps) {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [results, setResults] = useState<RadioBrowserStation[]>([]);
@@ -88,14 +95,21 @@ export default function RadioDirectoryModal({ onClose, onAdded }: RadioDirectory
if (addedIds.has(s.stationuuid) || addingId !== null) return;
setAddingId(s.stationuuid);
try {
await createInternetRadioStation(s.name, s.url);
onMutationStart();
await createInternetRadioStationForServer(targetServerId, s.name, s.url);
if (s.favicon) {
const list = await getInternetRadioStations().catch(() => [] as InternetRadioStation[]);
const created = list.find(r => r.streamUrl === s.url);
const list = await getInternetRadioStationsForServer(targetServerId)
.catch(() => [] as InternetRadioStation[]);
const created = list.find(r => r.name === s.name && r.streamUrl === s.url);
if (created) {
try {
const [fileBytes, mimeType] = await fetchUrlBytes(s.favicon);
await uploadRadioCoverArtBytes(created.id, fileBytes, mimeType);
await uploadRadioCoverArtBytesForServer(
targetServerId,
created.id,
fileBytes,
mimeType,
);
} catch { /* favicon optional */ }
}
}
@@ -4,8 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Camera, Cast, Loader2, X } from 'lucide-react';
import type { InternetRadioStation } from '@/lib/api/subsonicTypes';
import { CoverArtImage } from '@/cover/CoverArtImage';
import { albumCoverRef } from '@/cover/ref';
import { coverArtIdFromRadio } from '@/cover/ids';
import { radioCoverRef } from '@/cover/ref';
interface RadioEditModalProps {
station: InternetRadioStation | null; // null = create new
@@ -73,7 +72,7 @@ export default function RadioEditModal({ station, onClose, onSave }: RadioEditMo
style={{ maxWidth: 440, width: '90%', maxHeight: 'none', overflow: 'visible' }}
onClick={e => e.stopPropagation()}
>
<button className="btn btn-ghost modal-close" onClick={onClose} style={{ top: 16, right: 16 }}>
<button className="btn btn-ghost modal-close" onClick={onClose} aria-label={t('common.close')} style={{ top: 16, right: 16 }}>
<X size={18} />
</button>
@@ -93,7 +92,7 @@ export default function RadioEditModal({ station, onClose, onSave }: RadioEditMo
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : !coverRemoved && station?.coverArt ? (
<CoverArtImage
coverRef={albumCoverRef(coverArtIdFromRadio(station.id), coverArtIdFromRadio(station.id))}
coverRef={radioCoverRef(station)}
displayCssPx={140}
surface="sparse"
alt=""
@@ -134,6 +133,7 @@ export default function RadioEditModal({ station, onClose, onSave }: RadioEditMo
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('radio.stationName')}
aria-label={t('radio.stationName')}
autoFocus
/>
<input
@@ -141,12 +141,14 @@ export default function RadioEditModal({ station, onClose, onSave }: RadioEditMo
value={streamUrl}
onChange={e => setStreamUrl(e.target.value)}
placeholder={t('radio.streamUrl')}
aria-label={t('radio.streamUrl')}
/>
<input
className="input"
value={homepageUrl}
onChange={e => setHomepageUrl(e.target.value)}
placeholder={t('radio.homepageUrl')}
aria-label={t('radio.homepageUrl')}
/>
</div>
</div>
+5
View File
@@ -11,3 +11,8 @@
export { useRadioMetadata } from './hooks/useRadioMetadata';
export type { RadioMetadata } from './hooks/useRadioMetadata';
export { useRadioMprisSync } from './hooks/useRadioMprisSync';
export {
migrateRadioStationKeys,
radioStationKey,
sameRadioStation,
} from './utils/radioStationIdentity';
@@ -0,0 +1,226 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, waitFor } from '@testing-library/react';
import type { InternetRadioStation } from '@/lib/api/subsonicTypes';
const hoisted = vi.hoisted(() => ({
getSettled: vi.fn(),
getForServer: vi.fn(),
createForServer: vi.fn(),
updateForServer: vi.fn(),
deleteForServer: vi.fn(),
uploadCoverForServer: vi.fn(),
deleteCoverForServer: vi.fn(),
playRadio: vi.fn(),
stop: vi.fn(),
playerState: {
currentRadio: null as InternetRadioStation | null,
isPlaying: false,
volume: 1,
},
}));
vi.mock('@/lib/api/subsonicRadio', () => ({
getInternetRadioStationsForServersSettled: hoisted.getSettled,
getInternetRadioStationsForServer: hoisted.getForServer,
createInternetRadioStationForServer: hoisted.createForServer,
updateInternetRadioStationForServer: hoisted.updateForServer,
deleteInternetRadioStationForServer: hoisted.deleteForServer,
uploadRadioCoverArtForServer: hoisted.uploadCoverForServer,
deleteRadioCoverArtForServer: hoisted.deleteCoverForServer,
}));
vi.mock('@/features/playback/store/playerStore', () => {
const usePlayerStore = Object.assign(
(selector: (state: unknown) => unknown) => selector({
...hoisted.playerState,
playRadio: hoisted.playRadio,
stop: hoisted.stop,
}),
{
getState: () => ({ ...hoisted.playerState, stop: hoisted.stop }),
},
);
return { usePlayerStore };
});
vi.mock('@/features/playback/store/radioPlayer', () => ({ setRadioVolume: vi.fn() }));
vi.mock('@/features/playback/utils/playback/fadeOut', () => ({ fadeOut: vi.fn() }));
vi.mock('@/cover/radioCoverInvalidation', () => ({ invalidateRadioCoverArtCache: vi.fn() }));
vi.mock('@/lib/perf/perfFlags', () => ({
usePerfProbeFlags: () => ({ disableMainstageVirtualLists: true }),
}));
vi.mock('@/lib/hooks/useNavidromeAdminRole', () => ({
canManageNavidromeRadio: (role: string) => role !== 'user',
useNavidromeAdminRoles: (serverIds: string[]) => Object.fromEntries(
serverIds.map(serverId => [serverId, 'admin']),
),
}));
vi.mock('@/features/radio/components/RadioToolbar', () => ({ default: () => null }));
vi.mock('@/features/radio/components/AlphabetFilterBar', () => ({ default: () => null }));
vi.mock('@/features/radio/components/RadioCard', () => ({
default: ({
s,
serverLabel,
onDelete,
}: {
s: InternetRadioStation;
serverLabel?: string;
onDelete: (event: React.MouseEvent) => void;
}) => (
<div data-testid={`station-${s.serverId}-${s.id}`}>
<span>{s.name}</span>
{serverLabel && <span>{serverLabel}</span>}
<button onClick={onDelete}>delete {s.serverId}</button>
</div>
),
}));
vi.mock('@/features/radio/components/RadioEditModal', () => ({
default: ({ onSave }: {
onSave: (options: {
name: string;
streamUrl: string;
homepageUrl: string;
coverFile: File | null;
coverRemoved: boolean;
}) => Promise<void>;
}) => (
<button onClick={() => void onSave({
name: 'Created',
streamUrl: 'https://created.test/live',
homepageUrl: '',
coverFile: null,
coverRemoved: false,
})}>save station</button>
),
}));
vi.mock('@/features/radio/components/RadioDirectoryModal', () => ({ default: () => null }));
vi.mock('@/ui/VirtualCardGrid', () => ({
VirtualCardGrid: ({
items,
renderItem,
}: {
items: InternetRadioStation[];
renderItem: (item: InternetRadioStation) => React.ReactNode;
}) => <>{items.map(item => <React.Fragment key={`${item.serverId}:${item.id}`}>{renderItem(item)}</React.Fragment>)}</>,
}));
import InternetRadio from './InternetRadio';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { resetAuthStore } from '@/test/helpers/storeReset';
import { useAuthStore } from '@/store/authStore';
const DUPLICATE_STATIONS: InternetRadioStation[] = [
{ id: 'shared', serverId: 'srv-a', name: 'Alpha Radio', streamUrl: 'https://a.test/live' },
{ id: 'shared', serverId: 'srv-b', name: 'Beta Radio', streamUrl: 'https://b.test/live' },
];
describe('InternetRadio multi-server ownership', () => {
beforeEach(() => {
resetAuthStore();
localStorage.clear();
Object.values(hoisted).forEach(value => {
if (typeof value === 'function' && 'mockReset' in value) value.mockReset();
});
hoisted.playerState.currentRadio = null;
hoisted.playerState.isPlaying = false;
useAuthStore.setState({
isLoggedIn: true,
servers: [
{ id: 'srv-a', name: 'Home', url: 'https://a.test', username: 'a', password: 'p' },
{ id: 'srv-b', name: 'Remote', url: 'https://b.test', username: 'b', password: 'p' },
],
activeServerId: 'srv-a',
libraryBrowseServerIds: ['srv-a', 'srv-b'],
});
hoisted.getSettled.mockResolvedValue({ stations: DUPLICATE_STATIONS, failedServerIds: [] });
hoisted.getForServer.mockImplementation(async (serverId: string) => (
DUPLICATE_STATIONS.filter(station => station.serverId === serverId)
));
hoisted.createForServer.mockResolvedValue(undefined);
hoisted.deleteForServer.mockResolvedValue(undefined);
});
it('renders duplicate raw ids as distinct stations with source labels and owner-routed delete', async () => {
const view = renderWithProviders(<InternetRadio />);
expect(await view.findByTestId('station-srv-a-shared')).toHaveTextContent('Home');
expect(view.getByTestId('station-srv-b-shared')).toHaveTextContent('Remote');
expect(hoisted.getSettled).toHaveBeenCalledWith(['srv-a', 'srv-b']);
fireEvent.click(view.getByRole('button', { name: 'delete srv-b' }));
fireEvent.click(view.getByRole('button', { name: 'delete srv-b' }));
await waitFor(() => expect(hoisted.deleteForServer).toHaveBeenCalledWith('srv-b', 'shared'));
expect(view.getByTestId('station-srv-a-shared')).toBeInTheDocument();
expect(view.queryByTestId('station-srv-b-shared')).not.toBeInTheDocument();
});
it('creates on the explicitly selected target server', async () => {
const view = renderWithProviders(<InternetRadio />);
await view.findByTestId('station-srv-a-shared');
fireEvent.change(view.getByRole('combobox', { name: 'Servers' }), {
target: { value: 'srv-b' },
});
fireEvent.click(view.getByRole('button', { name: /add station/i }));
fireEvent.change(view.getByRole('combobox', { name: 'Servers' }), {
target: { value: 'srv-a' },
});
fireEvent.click(view.getByRole('button', { name: 'save station' }));
await waitFor(() => expect(hoisted.createForServer).toHaveBeenCalledWith(
'srv-b',
'Created',
'https://created.test/live',
undefined,
));
});
it('retains the previous owner slice when one selected server refresh fails', async () => {
hoisted.getSettled
.mockResolvedValueOnce({ stations: DUPLICATE_STATIONS, failedServerIds: [] })
.mockResolvedValueOnce({ stations: [DUPLICATE_STATIONS[0]], failedServerIds: ['srv-b'] });
const view = renderWithProviders(<InternetRadio />);
expect(await view.findByTestId('station-srv-b-shared')).toBeInTheDocument();
act(() => {
useAuthStore.setState(state => ({
servers: state.servers.map(server => server.id === 'srv-a'
? { ...server, name: 'Home updated' }
: server),
}));
});
await waitFor(() => expect(hoisted.getSettled).toHaveBeenCalledTimes(2));
expect(view.getByTestId('station-srv-b-shared')).toBeInTheDocument();
});
it('does not restore a deleted station from an older aggregate refresh', async () => {
let resolveRefresh: ((value: {
stations: InternetRadioStation[];
failedServerIds: string[];
}) => void) | undefined;
let resolveDelete: (() => void) | undefined;
hoisted.deleteForServer.mockImplementationOnce(() => new Promise<void>(resolve => {
resolveDelete = resolve;
}));
const view = renderWithProviders(<InternetRadio />);
expect(await view.findByTestId('station-srv-b-shared')).toBeInTheDocument();
fireEvent.click(view.getByRole('button', { name: 'delete srv-b' }));
fireEvent.click(view.getByRole('button', { name: 'delete srv-b' }));
await waitFor(() => expect(hoisted.deleteForServer).toHaveBeenCalledWith('srv-b', 'shared'));
hoisted.getSettled.mockImplementationOnce(() => new Promise(resolve => {
resolveRefresh = resolve;
}));
act(() => {
useAuthStore.setState(state => ({ servers: [...state.servers] }));
});
await waitFor(() => expect(hoisted.getSettled).toHaveBeenCalledTimes(2));
act(() => resolveDelete?.());
act(() => resolveRefresh?.({ stations: DUPLICATE_STATIONS, failedServerIds: [] }));
await view.findByTestId('station-srv-a-shared');
expect(view.queryByTestId('station-srv-b-shared')).not.toBeInTheDocument();
});
});
+215 -75
View File
@@ -1,11 +1,19 @@
import { getInternetRadioStations, createInternetRadioStation, updateInternetRadioStation, deleteInternetRadioStation, uploadRadioCoverArt, deleteRadioCoverArt } from '@/lib/api/subsonicRadio';
import {
createInternetRadioStationForServer,
deleteInternetRadioStationForServer,
deleteRadioCoverArtForServer,
getInternetRadioStationsForServer,
getInternetRadioStationsForServersSettled,
updateInternetRadioStationForServer,
uploadRadioCoverArtForServer,
} from '@/lib/api/subsonicRadio';
import { type InternetRadioStation } from '@/lib/api/subsonicTypes';
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import { Plus, Search } from 'lucide-react';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { setRadioVolume } from '@/features/playback/store/radioPlayer';
import { fadeOut } from '@/features/playback/utils/playback/fadeOut';
import { invalidateCoverArt } from '@/cover';
import { invalidateRadioCoverArtCache } from '@/cover/radioCoverInvalidation';
import { useTranslation } from 'react-i18next';
import { showToast } from '@/lib/dom/toast';
import RadioToolbar from '@/features/radio/components/RadioToolbar';
@@ -15,24 +23,49 @@ import RadioEditModal from '@/features/radio/components/RadioEditModal';
import RadioDirectoryModal from '@/features/radio/components/RadioDirectoryModal';
import { usePerfProbeFlags } from '@/lib/perf/perfFlags';
import { VirtualCardGrid } from '@/ui/VirtualCardGrid';
import { useNavidromeAdminRole, canManageNavidromeRadio } from '@/lib/hooks/useNavidromeAdminRole';
import { canManageNavidromeRadio, useNavidromeAdminRoles } from '@/lib/hooks/useNavidromeAdminRole';
import { useAuthStore } from '@/store/authStore';
import { deriveEffectiveLibraryBrowseServerIds } from '@/lib/library/libraryBrowseScope';
import { getUnavailableServerIds, useUnavailableServerIds } from '@/lib/network/serverReachability';
import { serverListDisplayLabel } from '@/lib/server/serverDisplayName';
import {
migrateRadioStationKeys,
radioStationKey,
sameRadioStation,
} from '@/features/radio/utils/radioStationIdentity';
export default function InternetRadio() {
const { t } = useTranslation();
const perfFlags = usePerfProbeFlags();
// Navidrome ≥ 0.62: only admins may create/edit/delete radio stations.
const canManage = canManageNavidromeRadio(useNavidromeAdminRole());
const playRadio = usePlayerStore(s => s.playRadio);
const stop = usePlayerStore(s => s.stop);
const currentRadio = usePlayerStore(s => s.currentRadio);
const isPlaying = usePlayerStore(s => s.isPlaying);
const servers = useAuthStore(s => s.servers);
const activeServerId = useAuthStore(s => s.activeServerId);
const libraryBrowseServerIds = useAuthStore(s => s.libraryBrowseServerIds);
const unavailableServerIds = useUnavailableServerIds();
const effectiveServerIds = useMemo(() => deriveEffectiveLibraryBrowseServerIds({
servers,
activeServerId,
libraryBrowseServerIds,
}, unavailableServerIds), [activeServerId, libraryBrowseServerIds, servers, unavailableServerIds]);
const adminRoles = useNavidromeAdminRoles(effectiveServerIds);
const serverLabelById = useMemo(() => new Map(
servers.map(server => [server.id, serverListDisplayLabel(server, servers)]),
), [servers]);
const [stations, setStations] = useState<InternetRadioStation[]>([]);
const [loading, setLoading] = useState(true);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
// null = closed, 'new' = create modal, InternetRadioStation = edit modal
const [modalStation, setModalStation] = useState<InternetRadioStation | 'new' | null>(null);
const [browseOpen, setBrowseOpen] = useState(false);
const [modalStation, setModalStation] = useState<
InternetRadioStation | { kind: 'new'; serverId: string } | null
>(null);
const [browseServerId, setBrowseServerId] = useState<string | null>(null);
const [requestedTargetServerId, setRequestedTargetServerId] = useState<string | null>(null);
const loadGenerationRef = useRef(0);
const mutationGenerationRef = useRef(0);
const reloadGenerationByServerRef = useRef(new Map<string, number>());
const [sortBy, setSortBy] = useState<'manual' | 'az' | 'za' | 'newest'>('manual');
const [activeFilter, setActiveFilter] = useState('all');
@@ -44,17 +77,72 @@ export default function InternetRadio() {
const [manualOrder, setManualOrder] = useState<string[]>([]);
const [dragOver, setDragOver] = useState<{ id: string; side: 'before' | 'after' } | null>(null);
const targetServerId = requestedTargetServerId && effectiveServerIds.includes(requestedTargetServerId)
? requestedTargetServerId
: activeServerId && effectiveServerIds.includes(activeServerId)
? activeServerId
: effectiveServerIds[0] ?? '';
const canManageTarget = Boolean(
targetServerId && canManageNavidromeRadio(adminRoles[targetServerId] ?? 'checking'),
);
useEffect(() => {
getInternetRadioStations()
.then(setStations)
.catch(() => {})
.finally(() => setLoading(false));
const generation = ++loadGenerationRef.current;
const mutationGeneration = mutationGenerationRef.current;
// React Compiler set-state-in-effect rule: reset loading before the scoped async read.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoading(true);
void getInternetRadioStationsForServersSettled(effectiveServerIds)
.then(({ stations: loaded, failedServerIds }) => {
if (
generation !== loadGenerationRef.current
|| mutationGeneration !== mutationGenerationRef.current
) return;
const failed = new Set(failedServerIds);
setStations(previous => effectiveServerIds.flatMap(serverId => failed.has(serverId)
? previous.filter(station => station.serverId === serverId)
: loaded.filter(station => station.serverId === serverId)));
})
.finally(() => {
if (generation === loadGenerationRef.current) setLoading(false);
});
return () => {
if (loadGenerationRef.current === generation) loadGenerationRef.current += 1;
};
}, [effectiveServerIds]);
const reloadServer = useCallback(async (serverId: string) => {
if (!serverId) return;
const generation = (reloadGenerationByServerRef.current.get(serverId) ?? 0) + 1;
reloadGenerationByServerRef.current.set(serverId, generation);
try {
const loaded = await getInternetRadioStationsForServer(serverId);
if (reloadGenerationByServerRef.current.get(serverId) !== generation) return;
const currentServerIds = deriveEffectiveLibraryBrowseServerIds(
useAuthStore.getState(),
getUnavailableServerIds(),
);
if (!currentServerIds.includes(serverId)) return;
setStations(previous => [
...previous.filter(station => station.serverId !== serverId),
...loaded,
]);
} catch {
// Keep the previous owner slice when its refresh fails.
}
}, []);
const reload = async () => {
const list = await getInternetRadioStations().catch(() => [] as InternetRadioStation[]);
setStations(list);
};
const beginMutation = useCallback((serverId: string) => {
mutationGenerationRef.current += 1;
reloadGenerationByServerRef.current.set(
serverId,
(reloadGenerationByServerRef.current.get(serverId) ?? 0) + 1,
);
}, []);
const completeMutation = useCallback(() => {
mutationGenerationRef.current += 1;
}, []);
// Merge saved manual order with current stations when stations change
useEffect(() => {
@@ -63,15 +151,30 @@ export default function InternetRadio() {
try { return JSON.parse(localStorage.getItem('psysonic_radio_order') ?? '[]'); }
catch { return []; }
})();
const currentIds = new Set(stations.map(s => s.id));
const merged = saved.filter((id: string) => currentIds.has(id));
stations.forEach(s => { if (!merged.includes(s.id)) merged.push(s.id); });
const merged = migrateRadioStationKeys(saved, stations, activeServerId);
stations.forEach(s => {
const key = radioStationKey(s);
if (!merged.includes(key)) merged.push(key);
});
localStorage.setItem('psysonic_radio_order', JSON.stringify(merged));
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setManualOrder(merged);
}, [stations]);
}, [stations, activeServerId]);
const toggleFavorite = useCallback((id: string) => {
useEffect(() => {
if (!stations.length) return;
// React Compiler set-state-in-effect rule: migrate persisted raw ids after owners load.
// eslint-disable-next-line react-hooks/set-state-in-effect
setFavorites(previous => {
const migrated = new Set(migrateRadioStationKeys([...previous], stations, activeServerId));
localStorage.setItem('psysonic_radio_favorites', JSON.stringify([...migrated]));
return migrated;
});
}, [stations, activeServerId]);
const toggleFavorite = useCallback((station: InternetRadioStation) => {
const id = radioStationKey(station);
setFavorites(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
@@ -98,13 +201,13 @@ export default function InternetRadio() {
// After chip-filter + sort, but before alphabet filter — used to compute available letters
const sortedFilteredStations = useMemo(() => {
let list = [...stations];
if (activeFilter === 'favorites') list = list.filter(s => favorites.has(s.id));
if (activeFilter === 'favorites') list = list.filter(s => favorites.has(radioStationKey(s)));
if (sortBy === 'az') list.sort((a, b) => a.name.localeCompare(b.name));
else if (sortBy === 'za') list.sort((a, b) => b.name.localeCompare(a.name));
else if (sortBy === 'newest') list.reverse();
else {
const orderMap = new Map(manualOrder.map((id, i) => [id, i]));
list.sort((a, b) => (orderMap.get(a.id) ?? 999) - (orderMap.get(b.id) ?? 999));
list.sort((a, b) => (orderMap.get(radioStationKey(a)) ?? 999) - (orderMap.get(radioStationKey(b)) ?? 999));
}
return list;
}, [stations, activeFilter, favorites, sortBy, manualOrder]);
@@ -135,36 +238,45 @@ export default function InternetRadio() {
coverFile: File | null;
coverRemoved: boolean;
}) => {
if (modalStation === 'new') {
await createInternetRadioStation(
if (modalStation && 'kind' in modalStation) {
const ownerServerId = modalStation.serverId;
beginMutation(ownerServerId);
await createInternetRadioStationForServer(
ownerServerId,
opts.name.trim(),
opts.streamUrl.trim(),
opts.homepageUrl.trim() || undefined
);
if (opts.coverFile) {
// Reload first to get the new station's ID, then upload cover
const updated = await getInternetRadioStations().catch(() => [] as InternetRadioStation[]);
const updated = await getInternetRadioStationsForServer(ownerServerId)
.catch(() => [] as InternetRadioStation[]);
const created = updated.find(
s => s.name === opts.name.trim() && s.streamUrl === opts.streamUrl.trim()
);
if (created) {
try {
await uploadRadioCoverArt(created.id, opts.coverFile);
await invalidateCoverArt(`ra-${created.id}`);
await uploadRadioCoverArtForServer(ownerServerId, created.id, opts.coverFile);
await invalidateRadioCoverArtCache(created);
} catch (err) {
showToast(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Cover upload failed', 4000, 'error');
}
// Reload again so coverArt field is picked up
await reload();
} else {
setStations(updated);
}
completeMutation();
// Reload again so coverArt and the concrete owner slice are current.
await reloadServer(ownerServerId);
} else {
await reload();
completeMutation();
await reloadServer(ownerServerId);
}
} else {
const id = (modalStation as InternetRadioStation).id;
await updateInternetRadioStation(
const station = modalStation as InternetRadioStation;
const id = station.id;
const ownerServerId = station.serverId;
if (!ownerServerId) return;
beginMutation(ownerServerId);
await updateInternetRadioStationForServer(
ownerServerId,
id,
opts.name.trim(),
opts.streamUrl.trim(),
@@ -172,27 +284,29 @@ export default function InternetRadio() {
);
if (opts.coverFile) {
try {
await uploadRadioCoverArt(id, opts.coverFile);
await invalidateCoverArt(`ra-${id}`);
await uploadRadioCoverArtForServer(ownerServerId, id, opts.coverFile);
await invalidateRadioCoverArtCache(station);
} catch (err) {
showToast(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Cover upload failed', 4000, 'error');
}
} else if (opts.coverRemoved) {
await deleteRadioCoverArt(id).catch(() => {});
await invalidateCoverArt(`ra-${id}`);
await deleteRadioCoverArtForServer(ownerServerId, id).catch(() => {});
await invalidateRadioCoverArtCache(station);
}
await reload();
completeMutation();
await reloadServer(ownerServerId);
}
setModalStation(null);
};
const handleDelete = async (e: React.MouseEvent, s: InternetRadioStation) => {
e.stopPropagation();
if (deleteConfirmId !== s.id) {
setDeleteConfirmId(s.id);
const stationKey = radioStationKey(s);
if (deleteConfirmId !== stationKey) {
setDeleteConfirmId(stationKey);
return;
}
if (currentRadio?.id === s.id) {
if (sameRadioStation(currentRadio, s)) {
if (isPlaying) {
const vol = usePlayerStore.getState().volume;
await fadeOut(setRadioVolume, vol, 700);
@@ -200,15 +314,18 @@ export default function InternetRadio() {
stop();
}
try {
await deleteInternetRadioStation(s.id);
setStations(prev => prev.filter(st => st.id !== s.id));
if (!s.serverId) throw new Error('Radio station owner unavailable');
beginMutation(s.serverId);
await deleteInternetRadioStationForServer(s.serverId, s.id);
completeMutation();
setStations(prev => prev.filter(st => radioStationKey(st) !== stationKey));
} catch { /* ignore: best-effort */ }
setDeleteConfirmId(null);
};
const handlePlay = (e: React.MouseEvent, s: InternetRadioStation) => {
e.stopPropagation();
if (currentRadio?.id === s.id && isPlaying) {
if (sameRadioStation(currentRadio, s) && isPlaying) {
stop();
} else {
playRadio(s);
@@ -229,16 +346,30 @@ export default function InternetRadio() {
{/* ── Header ── */}
<div className="playlists-header">
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('radio.title')}</h1>
{canManage && (
<div className="compact-action-bar" style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" onClick={() => setBrowseOpen(true)} aria-label={t('radio.browseDirectory')} data-tooltip={t('radio.browseDirectory')}>
<Search size={14} /> <span className="compact-btn-label">{t('radio.browseDirectory')}</span>
</button>
<button className="btn btn-primary" onClick={() => setModalStation('new')} aria-label={t('radio.addStation')} data-tooltip={t('radio.addStation')}>
<Plus size={15} /> <span className="compact-btn-label">{t('radio.addStation')}</span>
</button>
</div>
)}
<div className="compact-action-bar" style={{ display: 'flex', gap: 8 }}>
{effectiveServerIds.length > 1 && (
<select
className="input"
value={targetServerId}
onChange={event => setRequestedTargetServerId(event.target.value)}
aria-label={t('settings.servers')}
>
{effectiveServerIds.map(serverId => (
<option key={serverId} value={serverId}>
{serverLabelById.get(serverId) ?? serverId}
</option>
))}
</select>
)}
{canManageTarget && (<>
<button className="btn btn-primary" onClick={() => setBrowseServerId(targetServerId)} aria-label={t('radio.browseDirectory')} data-tooltip={t('radio.browseDirectory')}>
<Search size={14} /> <span className="compact-btn-label">{t('radio.browseDirectory')}</span>
</button>
<button className="btn btn-primary" onClick={() => setModalStation({ kind: 'new', serverId: targetServerId })} aria-label={t('radio.addStation')} data-tooltip={t('radio.addStation')}>
<Plus size={15} /> <span className="compact-btn-label">{t('radio.addStation')}</span>
</button>
</>)}
</div>
</div>
{/* ── Toolbar + Grid ── */}
@@ -262,28 +393,35 @@ export default function InternetRadio() {
) : (
<VirtualCardGrid
items={displayedStations}
itemKey={(s, _i) => s.id}
itemKey={(s, _i) => radioStationKey(s)}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={displayedStations.length}
renderItem={s => (
<RadioCard
s={s}
isActive={currentRadio?.id === s.id}
isActive={sameRadioStation(currentRadio, s)}
isPlaying={isPlaying}
deleteConfirmId={deleteConfirmId}
isFavorite={favorites.has(s.id)}
isFavorite={favorites.has(radioStationKey(s))}
isManual={sortBy === 'manual'}
canManage={canManage}
dropIndicator={dragOver?.id === s.id ? dragOver.side : null}
canManage={Boolean(
s.serverId && canManageNavidromeRadio(adminRoles[s.serverId] ?? 'checking'),
)}
serverLabel={effectiveServerIds.length > 1 && s.serverId
? serverLabelById.get(s.serverId)
: undefined}
dropIndicator={dragOver?.id === radioStationKey(s) ? dragOver.side : null}
onPlay={e => handlePlay(e, s)}
onDelete={e => handleDelete(e, s)}
onEdit={() => setModalStation(s)}
onFavoriteToggle={() => toggleFavorite(s.id)}
onDragEnter={side => setDragOver({ id: s.id, side })}
onDragLeave={() => setDragOver(prev => prev?.id === s.id ? null : prev)}
onDropOnto={(srcId, side) => handleReorder(srcId, s.id, side)}
onCardMouseLeave={() => { if (deleteConfirmId === s.id) setDeleteConfirmId(null); }}
onFavoriteToggle={() => toggleFavorite(s)}
onDragEnter={side => setDragOver({ id: radioStationKey(s), side })}
onDragLeave={() => setDragOver(prev => prev?.id === radioStationKey(s) ? null : prev)}
onDropOnto={(srcId, side) => handleReorder(srcId, radioStationKey(s), side)}
onCardMouseLeave={() => {
if (deleteConfirmId === radioStationKey(s)) setDeleteConfirmId(null);
}}
/>
)}
/>
@@ -292,19 +430,24 @@ export default function InternetRadio() {
)}
{/* ── Edit/Create Modal ── */}
{canManage && modalStation !== null && (
{modalStation !== null && (
<RadioEditModal
station={modalStation === 'new' ? null : modalStation}
station={'kind' in modalStation ? null : modalStation}
onClose={() => setModalStation(null)}
onSave={handleSave}
/>
)}
{/* ── Directory Modal ── */}
{canManage && browseOpen && (
{browseServerId && (
<RadioDirectoryModal
onClose={() => setBrowseOpen(false)}
onAdded={reload}
targetServerId={browseServerId}
onMutationStart={() => beginMutation(browseServerId)}
onClose={() => setBrowseServerId(null)}
onAdded={() => {
completeMutation();
return reloadServer(browseServerId);
}}
/>
)}
</div>
@@ -312,6 +455,3 @@ export default function InternetRadio() {
}
// ── Toolbar ───────────────────────────────────────────────────────────────────
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import {
migrateRadioStationKeys,
radioStationKey,
sameRadioStation,
} from './radioStationIdentity';
const stations = [
{ id: 'shared', serverId: 'srv-a', name: 'A', streamUrl: 'https://a.test/live' },
{ id: 'shared', serverId: 'srv-b', name: 'B', streamUrl: 'https://b.test/live' },
];
describe('radioStationIdentity', () => {
it('keeps duplicate raw ids distinct by owner', () => {
expect(radioStationKey(stations[0])).toBe('srv-a:shared');
expect(radioStationKey(stations[1])).toBe('srv-b:shared');
expect(sameRadioStation(stations[0], stations[1])).toBe(false);
});
it('migrates a legacy raw id to the preferred owner and preserves unavailable keys', () => {
expect(migrateRadioStationKeys(
['shared', 'srv-c:missing'],
stations,
'srv-b',
)).toEqual(['srv-b:shared', 'srv-c:missing']);
});
it('does not assign a raw id to another owner while the preferred owner is absent', () => {
expect(migrateRadioStationKeys(
['shared'],
[stations[0]],
'srv-b',
)).toEqual(['shared']);
});
});
@@ -0,0 +1,40 @@
import type { InternetRadioStation } from '@/lib/api/subsonicTypes';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
export function radioStationKey(station: Pick<InternetRadioStation, 'id' | 'serverId'>): string {
return ownedEntityKey(station);
}
export function sameRadioStation(
a: Pick<InternetRadioStation, 'id' | 'serverId'> | null | undefined,
b: Pick<InternetRadioStation, 'id' | 'serverId'> | null | undefined,
): boolean {
return Boolean(a && b && radioStationKey(a) === radioStationKey(b));
}
/** Convert persisted raw ids to one concrete owner without dropping unavailable-owner keys. */
export function migrateRadioStationKeys(
keys: readonly string[],
stations: readonly InternetRadioStation[],
preferredServerId?: string | null,
): string[] {
const exactKeys = new Set(stations.map(radioStationKey));
const stationsByRawId = new Map<string, InternetRadioStation[]>();
for (const station of stations) {
const matches = stationsByRawId.get(station.id) ?? [];
matches.push(station);
stationsByRawId.set(station.id, matches);
}
const migrated = keys.map(key => {
if (exactKeys.has(key)) return key;
const candidates = stationsByRawId.get(key);
if (!candidates?.length) return key;
if (preferredServerId) {
const preferred = candidates.find(station => station.serverId === preferredServerId);
return preferred ? radioStationKey(preferred) : key;
}
return candidates.length === 1 ? radioStationKey(candidates[0]) : key;
});
return [...new Set(migrated)];
}
+139
View File
@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({
api: vi.fn(),
apiForServer: vi.fn(),
uploadRadioCover: vi.fn(),
deleteRadioCover: vi.fn(),
findServerByIdOrIndexKey: vi.fn(),
}));
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }));
vi.mock('@/generated/bindings', () => ({
commands: {
uploadRadioCover: hoisted.uploadRadioCover,
deleteRadioCover: hoisted.deleteRadioCover,
fetchUrlBytes: vi.fn(),
},
}));
vi.mock('@/store/authStore', () => ({
useAuthStore: { getState: () => ({ activeServerId: 'srv-active' }) },
}));
vi.mock('@/lib/api/subsonicClient', () => ({
api: hoisted.api,
apiForServer: hoisted.apiForServer,
}));
vi.mock('@/lib/network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForServer: vi.fn(() => true),
}));
vi.mock('@/lib/server/serverLookup', () => ({
findServerByIdOrIndexKey: hoisted.findServerByIdOrIndexKey,
}));
vi.mock('@/lib/server/serverEndpoint', () => ({
connectBaseUrlForServer: (server: { id: string }) => `https://${server.id}.test`,
}));
import {
createInternetRadioStationForServer,
deleteInternetRadioStationForServer,
getInternetRadioStationsForServersSettled,
updateInternetRadioStationForServer,
uploadRadioCoverArtBytesForServer,
} from './subsonicRadio';
describe('subsonicRadio explicit server ownership', () => {
beforeEach(() => {
Object.values(hoisted).forEach(mock => mock.mockReset());
hoisted.uploadRadioCover.mockResolvedValue({ status: 'ok', data: null });
hoisted.findServerByIdOrIndexKey.mockImplementation((serverId: string) => ({
id: serverId,
username: `${serverId}-user`,
password: `${serverId}-password`,
}));
});
it('preserves duplicate raw ids from successful owners and reports partial failures', async () => {
hoisted.apiForServer.mockImplementation(async (serverId: string) => {
if (serverId === 'srv-b') throw new Error('offline');
return {
internetRadioStations: {
internetRadioStation: [{
id: 'shared',
name: `${serverId} Radio`,
streamUrl: `https://${serverId}.test/live`,
}],
},
};
});
await expect(getInternetRadioStationsForServersSettled([
'srv-a',
'srv-b',
'srv-a',
'srv-c',
])).resolves.toEqual({
stations: [
{
id: 'shared',
serverId: 'srv-a',
name: 'srv-a Radio',
streamUrl: 'https://srv-a.test/live',
},
{
id: 'shared',
serverId: 'srv-c',
name: 'srv-c Radio',
streamUrl: 'https://srv-c.test/live',
},
],
failedServerIds: ['srv-b'],
});
});
it('routes create, update, delete, and cover upload to the captured owner', async () => {
hoisted.apiForServer.mockResolvedValue({});
await createInternetRadioStationForServer('srv-owner', 'One', 'https://one.test/live');
await updateInternetRadioStationForServer(
'srv-owner',
'radio-1',
'Updated',
'https://updated.test/live',
'https://updated.test',
);
await deleteInternetRadioStationForServer('srv-owner', 'radio-1');
await uploadRadioCoverArtBytesForServer('srv-owner', 'radio-1', [1, 2, 3], 'image/png');
expect(hoisted.apiForServer).toHaveBeenNthCalledWith(
1,
'srv-owner',
'createInternetRadioStation.view',
{ name: 'One', streamUrl: 'https://one.test/live' },
);
expect(hoisted.apiForServer).toHaveBeenNthCalledWith(
2,
'srv-owner',
'updateInternetRadioStation.view',
{
id: 'radio-1',
name: 'Updated',
streamUrl: 'https://updated.test/live',
homepageUrl: 'https://updated.test',
},
);
expect(hoisted.apiForServer).toHaveBeenNthCalledWith(
3,
'srv-owner',
'deleteInternetRadioStation.view',
{ id: 'radio-1' },
);
expect(hoisted.uploadRadioCover).toHaveBeenCalledWith(
'https://srv-owner.test',
'radio-1',
'srv-owner-user',
'srv-owner-password',
[1, 2, 3],
'image/png',
);
});
});
+114 -19
View File
@@ -1,20 +1,54 @@
import { invoke } from '@tauri-apps/api/core';
import { commands } from '@/generated/bindings';
import { useAuthStore } from '@/store/authStore';
import { api } from '@/lib/api/subsonicClient';
import { api, apiForServer } from '@/lib/api/subsonicClient';
import type { InternetRadioStation, RadioBrowserStation } from '@/lib/api/subsonicTypes';
import { shouldAttemptSubsonicForServer } from '@/lib/network/subsonicNetworkGuard';
import { findServerByIdOrIndexKey } from '@/lib/server/serverLookup';
import { connectBaseUrlForServer } from '@/lib/server/serverEndpoint';
type InternetRadioResponse = {
internetRadioStations?: { internetRadioStation?: InternetRadioStation[] };
};
function radioStationsFromResponse(data: InternetRadioResponse): InternetRadioStation[] {
return data.internetRadioStations?.internetRadioStation ?? [];
}
export async function getInternetRadioStations(): Promise<InternetRadioStation[]> {
try {
const data = await api<{ internetRadioStations?: { internetRadioStation?: InternetRadioStation[] } }>(
'getInternetRadioStations.view'
);
return data.internetRadioStations?.internetRadioStation ?? [];
return radioStationsFromResponse(await api<InternetRadioResponse>('getInternetRadioStations.view'));
} catch {
return [];
}
}
export async function getInternetRadioStationsForServer(
serverId: string,
): Promise<InternetRadioStation[]> {
if (!shouldAttemptSubsonicForServer(serverId)) throw new Error('Subsonic unavailable');
const data = await apiForServer<InternetRadioResponse>(serverId, 'getInternetRadioStations.view');
return radioStationsFromResponse(data).map(station => ({ ...station, serverId }));
}
export interface InternetRadioStationsForServersResult {
stations: InternetRadioStation[];
failedServerIds: string[];
}
export async function getInternetRadioStationsForServersSettled(
serverIds: string[],
): Promise<InternetRadioStationsForServersResult> {
const uniqueServerIds = [...new Set(serverIds.filter(Boolean))];
const results = await Promise.allSettled(
uniqueServerIds.map(serverId => getInternetRadioStationsForServer(serverId)),
);
return {
stations: results.flatMap(result => result.status === 'fulfilled' ? result.value : []),
failedServerIds: uniqueServerIds.filter((_serverId, index) => results[index]?.status === 'rejected'),
};
}
export async function createInternetRadioStation(
name: string, streamUrl: string, homepageUrl?: string
): Promise<void> {
@@ -23,6 +57,14 @@ export async function createInternetRadioStation(
await api('createInternetRadioStation.view', params);
}
export async function createInternetRadioStationForServer(
serverId: string, name: string, streamUrl: string, homepageUrl?: string,
): Promise<void> {
const params: Record<string, unknown> = { name, streamUrl };
if (homepageUrl) params.homepageUrl = homepageUrl;
await apiForServer(serverId, 'createInternetRadioStation.view', params);
}
export async function updateInternetRadioStation(
id: string, name: string, streamUrl: string, homepageUrl?: string
): Promise<void> {
@@ -31,35 +73,88 @@ export async function updateInternetRadioStation(
await api('updateInternetRadioStation.view', params);
}
export async function updateInternetRadioStationForServer(
serverId: string, id: string, name: string, streamUrl: string, homepageUrl?: string,
): Promise<void> {
const params: Record<string, unknown> = { id, name, streamUrl };
if (homepageUrl) params.homepageUrl = homepageUrl;
await apiForServer(serverId, 'updateInternetRadioStation.view', params);
}
export async function deleteInternetRadioStation(id: string): Promise<void> {
await api('deleteInternetRadioStation.view', { id });
}
export async function deleteInternetRadioStationForServer(serverId: string, id: string): Promise<void> {
await apiForServer(serverId, 'deleteInternetRadioStation.view', { id });
}
export async function uploadRadioCoverArt(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const serverId = useAuthStore.getState().activeServerId;
if (!serverId) throw new Error('No active server');
return uploadRadioCoverArtForServer(serverId, id, file);
}
export async function uploadRadioCoverArtForServer(
serverId: string,
id: string,
file: File,
): Promise<void> {
const server = findServerByIdOrIndexKey(serverId);
if (!server) throw new Error('Server not found');
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
const res = await commands.uploadRadioCover(baseUrl, id, server?.username ?? '', server?.password ?? '', fileBytes, file.type || 'image/jpeg');
const res = await commands.uploadRadioCover(
connectBaseUrlForServer(server),
id,
server.username,
server.password,
fileBytes,
file.type || 'image/jpeg',
);
if (res.status === 'error') throw new Error(res.error);
}
export async function deleteRadioCoverArt(id: string): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const res = await commands.deleteRadioCover(baseUrl, id, server?.username ?? '', server?.password ?? '');
const serverId = useAuthStore.getState().activeServerId;
if (!serverId) throw new Error('No active server');
return deleteRadioCoverArtForServer(serverId, id);
}
export async function deleteRadioCoverArtForServer(serverId: string, id: string): Promise<void> {
const server = findServerByIdOrIndexKey(serverId);
if (!server) throw new Error('Server not found');
const res = await commands.deleteRadioCover(
connectBaseUrlForServer(server),
id,
server.username,
server.password,
);
if (res.status === 'error') throw new Error(res.error);
}
export async function uploadRadioCoverArtBytes(id: string, fileBytes: number[], mimeType: string): Promise<void> {
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const res = await commands.uploadRadioCover(baseUrl, id, server?.username ?? '', server?.password ?? '', fileBytes, mimeType);
const serverId = useAuthStore.getState().activeServerId;
if (!serverId) throw new Error('No active server');
return uploadRadioCoverArtBytesForServer(serverId, id, fileBytes, mimeType);
}
export async function uploadRadioCoverArtBytesForServer(
serverId: string,
id: string,
fileBytes: number[],
mimeType: string,
): Promise<void> {
const server = findServerByIdOrIndexKey(serverId);
if (!server) throw new Error('Server not found');
const res = await commands.uploadRadioCover(
connectBaseUrlForServer(server),
id,
server.username,
server.password,
fileBytes,
mimeType,
);
if (res.status === 'error') throw new Error(res.error);
}
+2
View File
@@ -115,6 +115,8 @@ export interface SubsonicSong {
export interface InternetRadioStation {
id: string;
/** Owning server profile when radio stations are aggregated across a Library scope. */
serverId?: string;
name: string;
streamUrl: string;
homepageUrl?: string;
+60 -2
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { resetAuthStore } from '@/test/helpers/storeReset';
import { useAuthStore } from '@/store/authStore';
@@ -8,7 +8,11 @@ vi.mock('@/lib/api/navidromeAdmin', () => ({
}));
import { ndLogin } from '@/lib/api/navidromeAdmin';
import { useNavidromeAdminRole, canManageNavidromeRadio } from './useNavidromeAdminRole';
import {
useNavidromeAdminRole,
useNavidromeAdminRoles,
canManageNavidromeRadio,
} from './useNavidromeAdminRole';
beforeEach(() => {
resetAuthStore();
@@ -111,6 +115,60 @@ describe('useNavidromeAdminRole', () => {
const { result } = renderHook(() => useNavidromeAdminRole());
await waitFor(() => expect(result.current).toBe('error'));
});
it('resolves admin roles independently for multiple server owners', async () => {
const first = seedNavidromeServer();
const second = useAuthStore.getState().addServer({
name: 'Remote',
url: 'https://remote.example.com',
username: 'remote',
password: 'pw2',
});
useAuthStore.getState().setSubsonicServerIdentity(second, {
type: 'navidrome',
serverVersion: '0.62.0',
openSubsonic: true,
});
vi.mocked(ndLogin).mockImplementation(async url => ({
token: 't',
userId: '1',
isAdmin: url.includes('music.example.com'),
}));
const { result } = renderHook(() => useNavidromeAdminRoles([first, second]));
await waitFor(() => expect(result.current).toEqual({
[first]: 'admin',
[second]: 'user',
}));
});
it('publishes one owner role without waiting for another probe', async () => {
const first = seedNavidromeServer();
const second = useAuthStore.getState().addServer({
name: 'Remote',
url: 'https://remote.example.com',
username: 'remote',
password: 'pw2',
});
useAuthStore.getState().setSubsonicServerIdentity(second, {
type: 'navidrome',
serverVersion: '0.62.0',
openSubsonic: true,
});
let resolveRemote: ((value: { token: string; userId: string; isAdmin: boolean }) => void) | undefined;
vi.mocked(ndLogin).mockImplementation(url => url.includes('music.example.com')
? Promise.resolve({ token: 't', userId: '1', isAdmin: false })
: new Promise(resolve => { resolveRemote = resolve; }));
const { result } = renderHook(() => useNavidromeAdminRoles([first, second]));
await waitFor(() => expect(result.current).toEqual({
[first]: 'user',
[second]: 'checking',
}));
act(() => resolveRemote?.({ token: 't2', userId: '2', isAdmin: true }));
await waitFor(() => expect(result.current[second]).toBe('admin'));
});
});
describe('canManageNavidromeRadio', () => {
+77 -54
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { ndLogin } from '@/lib/api/navidromeAdmin';
import { useAuthStore } from '@/store/authStore';
import { isNavidromeServer } from '@/lib/server/subsonicServerIdentity';
import type { SubsonicServerIdentity } from '@/lib/server/subsonicServerIdentity';
export type NavidromeAdminRole = 'idle' | 'checking' | 'admin' | 'user' | 'na' | 'error';
@@ -21,64 +22,86 @@ function normalizeServerUrl(url: string): string {
return withScheme.replace(/\/$/, '');
}
function roleBeforeProbe(
isLoggedIn: boolean,
server: { url: string } | undefined,
identity: SubsonicServerIdentity | undefined,
): NavidromeAdminRole {
if (!isLoggedIn || !server) return 'na';
if (!identity) return 'checking';
return isNavidromeServer(identity) ? 'checking' : 'na';
}
export function useNavidromeAdminRoles(serverIds: readonly string[]): Record<string, NavidromeAdminRole> {
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
const servers = useAuthStore(s => s.servers);
const identities = useAuthStore(s => s.subsonicServerIdentityByServer);
const serverIdsKey = [...new Set(serverIds.filter(Boolean))].join('\0');
const requestedServerIds = useMemo(
() => serverIdsKey ? serverIdsKey.split('\0') : [],
[serverIdsKey],
);
const probeKey = JSON.stringify(requestedServerIds.map(serverId => {
const server = servers.find(candidate => candidate.id === serverId);
const identity = identities[serverId];
return [
serverId,
isLoggedIn,
server?.url,
server?.username,
server?.password,
identity?.type,
identity?.serverVersion,
];
}));
const initialRoles = useMemo(() => Object.fromEntries(requestedServerIds.map(serverId => [
serverId,
roleBeforeProbe(
isLoggedIn,
servers.find(server => server.id === serverId),
identities[serverId],
),
])), [requestedServerIds, isLoggedIn, servers, identities]);
const [resolved, setResolved] = useState<{
key: string;
roles: Record<string, NavidromeAdminRole>;
}>({ key: '', roles: {} });
useEffect(() => {
let cancelled = false;
// React Compiler set-state-in-effect rule: reset role snapshots when the requested owners change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setResolved({ key: probeKey, roles: initialRoles });
for (const serverId of requestedServerIds) {
const server = servers.find(candidate => candidate.id === serverId);
const identity = identities[serverId];
if (!isLoggedIn || !server || !identity || !isNavidromeServer(identity)) continue;
void ndLogin(normalizeServerUrl(server.url), server.username, server.password)
.then(result => result.isAdmin ? 'admin' as const : 'user' as const)
.catch(() => 'error' as const)
.then(role => {
if (cancelled) return;
setResolved(previous => previous.key === probeKey
? { key: probeKey, roles: { ...previous.roles, [serverId]: role } }
: { key: probeKey, roles: { ...initialRoles, [serverId]: role } });
});
}
return () => { cancelled = true; };
}, [probeKey, initialRoles, requestedServerIds, servers, identities, isLoggedIn]);
return resolved.key === probeKey ? resolved.roles : initialRoles;
}
/**
* Probes Navidrome native login for the active server to learn whether the
* current Subsonic credentials belong to an admin account.
*/
export function useNavidromeAdminRole(): NavidromeAdminRole {
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
const activeServerId = useAuthStore(s => s.activeServerId);
const server = useAuthStore(s => s.servers.find(srv => srv.id === s.activeServerId));
const identity = useAuthStore(s =>
activeServerId ? s.subsonicServerIdentityByServer[activeServerId] : undefined,
const requestedServerIds = useMemo(
() => activeServerId ? [activeServerId] : [],
[activeServerId],
);
const [role, setRole] = useState<NavidromeAdminRole>('idle');
useEffect(() => {
if (!isLoggedIn || !server) {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setRole('na');
return;
}
if (!identity) {
setRole('checking');
return;
}
if (!isNavidromeServer(identity)) {
setRole('na');
return;
}
let cancelled = false;
setRole('checking');
const serverUrl = normalizeServerUrl(server.url);
ndLogin(serverUrl, server.username, server.password)
.then(res => {
if (cancelled) return;
setRole(res.isAdmin ? 'admin' : 'user');
})
.catch(() => {
if (!cancelled) setRole('error');
});
return () => {
cancelled = true;
};
// Keyed on the server's and identity's primitive fields; depending on the
// `server` / `identity` objects would re-probe the admin role on every render
// when their identities change but their fields do not.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
isLoggedIn,
activeServerId,
server?.id,
server?.url,
server?.username,
server?.password,
identity?.type,
identity?.serverVersion,
]);
return role;
const roles = useNavidromeAdminRoles(requestedServerIds);
return activeServerId ? roles[activeServerId] ?? 'checking' : 'na';
}