mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
fix(radio): preserve server ownership across flows
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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 */ }
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 effect’s 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)];
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 effect’s 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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user