From 011f2d9d46605f2a385ee17b04fd5a54119d11f5 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Sun, 19 Jul 2026 04:08:48 +0300 Subject: [PATCH] fix(radio): preserve server ownership across flows --- .../tauriBridge/usePlayerSnapshotPublisher.ts | 3 +- src/cover/radioCoverInvalidation.test.ts | 40 +++ src/cover/radioCoverInvalidation.ts | 18 ++ src/cover/ref.test.ts | 10 + src/cover/ref.ts | 15 +- .../favorites/components/RadioFavorites.tsx | 35 ++- .../favorites/hooks/useFavoritesData.test.ts | 96 ++++++ .../favorites/hooks/useFavoritesData.ts | 71 ++++- src/features/favorites/pages/Favorites.tsx | 3 +- .../nowPlaying/hooks/useNowPlayingPrewarm.ts | 11 +- src/features/nowPlaying/pages/NowPlaying.tsx | 6 +- .../components/playerBar/PlayerTrackInfo.tsx | 4 +- .../audioListenerSetup/mprisSync.test.ts | 64 ++++ .../store/audioListenerSetup/mprisSync.ts | 10 +- .../playback/store/miscActions.radio.test.ts | 90 ++++++ src/features/playback/store/miscActions.ts | 6 +- src/features/radio/components/RadioCard.tsx | 29 +- .../components/RadioDirectoryModal.test.tsx | 81 +++++ .../radio/components/RadioDirectoryModal.tsx | 28 +- .../radio/components/RadioEditModal.tsx | 10 +- src/features/radio/index.ts | 5 + .../radio/pages/InternetRadio.test.tsx | 226 ++++++++++++++ src/features/radio/pages/InternetRadio.tsx | 290 +++++++++++++----- .../radio/utils/radioStationIdentity.test.ts | 35 +++ .../radio/utils/radioStationIdentity.ts | 40 +++ src/lib/api/subsonicRadio.server.test.ts | 139 +++++++++ src/lib/api/subsonicRadio.ts | 133 ++++++-- src/lib/api/subsonicTypes.ts | 2 + src/lib/hooks/useNavidromeAdminRole.test.ts | 62 +++- src/lib/hooks/useNavidromeAdminRole.ts | 131 ++++---- 30 files changed, 1476 insertions(+), 217 deletions(-) create mode 100644 src/cover/radioCoverInvalidation.test.ts create mode 100644 src/cover/radioCoverInvalidation.ts create mode 100644 src/features/favorites/hooks/useFavoritesData.test.ts create mode 100644 src/features/playback/store/audioListenerSetup/mprisSync.test.ts create mode 100644 src/features/playback/store/miscActions.radio.test.ts create mode 100644 src/features/radio/components/RadioDirectoryModal.test.tsx create mode 100644 src/features/radio/pages/InternetRadio.test.tsx create mode 100644 src/features/radio/utils/radioStationIdentity.test.ts create mode 100644 src/features/radio/utils/radioStationIdentity.ts create mode 100644 src/lib/api/subsonicRadio.server.test.ts diff --git a/src/app/tauriBridge/usePlayerSnapshotPublisher.ts b/src/app/tauriBridge/usePlayerSnapshotPublisher.ts index 2fc279e0..039a03a5 100644 --- a/src/app/tauriBridge/usePlayerSnapshotPublisher.ts +++ b/src/app/tauriBridge/usePlayerSnapshotPublisher.ts @@ -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, diff --git a/src/cover/radioCoverInvalidation.test.ts b/src/cover/radioCoverInvalidation.test.ts new file mode 100644 index 00000000..838e3379 --- /dev/null +++ b/src/cover/radioCoverInvalidation.test.ts @@ -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'); + }); +}); diff --git a/src/cover/radioCoverInvalidation.ts b/src/cover/radioCoverInvalidation.ts new file mode 100644 index 00000000..e3b514c3 --- /dev/null +++ b/src/cover/radioCoverInvalidation.ts @@ -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, +): Promise { + 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); +} diff --git a/src/cover/ref.test.ts b/src/cover/ref.test.ts index 29ecec9d..4cad2d33 100644 --- a/src/cover/ref.test.ts +++ b/src/cover/ref.test.ts @@ -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'); diff --git a/src/cover/ref.ts b/src/cover/ref.ts index 98163193..c4255ceb 100644 --- a/src/cover/ref.ts +++ b/src/cover/ref.ts @@ -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, +): 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, distinctDiscCovers?: boolean, diff --git a/src/features/favorites/components/RadioFavorites.tsx b/src/features/favorites/components/RadioFavorites.tsx index e5c89561..963e5431 100644 --- a/src/features/favorites/components/RadioFavorites.tsx +++ b/src/features/favorites/components/RadioFavorites.tsx @@ -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(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
{stations.map(s => ( onPlay(s)} - onUnfavorite={() => onUnfavorite(s.id)} + onUnfavorite={() => onUnfavorite(s)} /> ))}
@@ -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 (
{s.coverArt ? ( )}
-
@@ -104,10 +117,12 @@ function RadioFavCard({ station: s, isActive, isPlaying, onPlay, onUnfavorite }:
{s.name}
+ {serverLabel && {serverLabel}}
)} @@ -142,9 +146,11 @@ export default function RadioCard({ {t('radio.editStation')} )} + {serverLabel && {serverLabel}} @@ -93,7 +92,7 @@ export default function RadioEditModal({ station, onClose, onSave }: RadioEditMo ) : !coverRemoved && station?.coverArt ? ( setName(e.target.value)} placeholder={t('radio.stationName')} + aria-label={t('radio.stationName')} autoFocus /> setStreamUrl(e.target.value)} placeholder={t('radio.streamUrl')} + aria-label={t('radio.streamUrl')} /> setHomepageUrl(e.target.value)} placeholder={t('radio.homepageUrl')} + aria-label={t('radio.homepageUrl')} />
diff --git a/src/features/radio/index.ts b/src/features/radio/index.ts index 7438203b..81348a9c 100644 --- a/src/features/radio/index.ts +++ b/src/features/radio/index.ts @@ -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'; diff --git a/src/features/radio/pages/InternetRadio.test.tsx b/src/features/radio/pages/InternetRadio.test.tsx new file mode 100644 index 00000000..afca4019 --- /dev/null +++ b/src/features/radio/pages/InternetRadio.test.tsx @@ -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; + }) => ( +
+ {s.name} + {serverLabel && {serverLabel}} + +
+ ), +})); +vi.mock('@/features/radio/components/RadioEditModal', () => ({ + default: ({ onSave }: { + onSave: (options: { + name: string; + streamUrl: string; + homepageUrl: string; + coverFile: File | null; + coverRemoved: boolean; + }) => Promise; + }) => ( + + ), +})); +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 => {renderItem(item)})}, +})); + +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(); + + 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(); + 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(); + 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(resolve => { + resolveDelete = resolve; + })); + const view = renderWithProviders(); + 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(); + }); +}); diff --git a/src/features/radio/pages/InternetRadio.tsx b/src/features/radio/pages/InternetRadio.tsx index 7022e26d..83a970e2 100644 --- a/src/features/radio/pages/InternetRadio.tsx +++ b/src/features/radio/pages/InternetRadio.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [deleteConfirmId, setDeleteConfirmId] = useState(null); - // null = closed, 'new' = create modal, InternetRadioStation = edit modal - const [modalStation, setModalStation] = useState(null); - const [browseOpen, setBrowseOpen] = useState(false); + const [modalStation, setModalStation] = useState< + InternetRadioStation | { kind: 'new'; serverId: string } | null + >(null); + const [browseServerId, setBrowseServerId] = useState(null); + const [requestedTargetServerId, setRequestedTargetServerId] = useState(null); + const loadGenerationRef = useRef(0); + const mutationGenerationRef = useRef(0); + const reloadGenerationByServerRef = useRef(new Map()); 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([]); 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 ── */}

{t('radio.title')}

- {canManage && ( -
- - -
- )} +
+ {effectiveServerIds.length > 1 && ( + + )} + {canManageTarget && (<> + + + )} +
{/* ── Toolbar + Grid ── */} @@ -262,28 +393,35 @@ export default function InternetRadio() { ) : ( s.id} + itemKey={(s, _i) => radioStationKey(s)} rowVariant="album" disableVirtualization={perfFlags.disableMainstageVirtualLists} layoutSignal={displayedStations.length} renderItem={s => ( 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 && ( setModalStation(null)} onSave={handleSave} /> )} {/* ── Directory Modal ── */} - {canManage && browseOpen && ( + {browseServerId && ( setBrowseOpen(false)} - onAdded={reload} + targetServerId={browseServerId} + onMutationStart={() => beginMutation(browseServerId)} + onClose={() => setBrowseServerId(null)} + onAdded={() => { + completeMutation(); + return reloadServer(browseServerId); + }} /> )}
@@ -312,6 +455,3 @@ export default function InternetRadio() { } // ── Toolbar ─────────────────────────────────────────────────────────────────── - - - diff --git a/src/features/radio/utils/radioStationIdentity.test.ts b/src/features/radio/utils/radioStationIdentity.test.ts new file mode 100644 index 00000000..03291667 --- /dev/null +++ b/src/features/radio/utils/radioStationIdentity.test.ts @@ -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']); + }); +}); diff --git a/src/features/radio/utils/radioStationIdentity.ts b/src/features/radio/utils/radioStationIdentity.ts new file mode 100644 index 00000000..462b9758 --- /dev/null +++ b/src/features/radio/utils/radioStationIdentity.ts @@ -0,0 +1,40 @@ +import type { InternetRadioStation } from '@/lib/api/subsonicTypes'; +import { ownedEntityKey } from '@/lib/util/ownedEntityKey'; + +export function radioStationKey(station: Pick): string { + return ownedEntityKey(station); +} + +export function sameRadioStation( + a: Pick | null | undefined, + b: Pick | 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(); + 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)]; +} diff --git a/src/lib/api/subsonicRadio.server.test.ts b/src/lib/api/subsonicRadio.server.test.ts new file mode 100644 index 00000000..7663b2e2 --- /dev/null +++ b/src/lib/api/subsonicRadio.server.test.ts @@ -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', + ); + }); +}); diff --git a/src/lib/api/subsonicRadio.ts b/src/lib/api/subsonicRadio.ts index 2b61539b..ab709c3e 100644 --- a/src/lib/api/subsonicRadio.ts +++ b/src/lib/api/subsonicRadio.ts @@ -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 { try { - const data = await api<{ internetRadioStations?: { internetRadioStation?: InternetRadioStation[] } }>( - 'getInternetRadioStations.view' - ); - return data.internetRadioStations?.internetRadioStation ?? []; + return radioStationsFromResponse(await api('getInternetRadioStations.view')); } catch { return []; } } +export async function getInternetRadioStationsForServer( + serverId: string, +): Promise { + if (!shouldAttemptSubsonicForServer(serverId)) throw new Error('Subsonic unavailable'); + const data = await apiForServer(serverId, 'getInternetRadioStations.view'); + return radioStationsFromResponse(data).map(station => ({ ...station, serverId })); +} + +export interface InternetRadioStationsForServersResult { + stations: InternetRadioStation[]; + failedServerIds: string[]; +} + +export async function getInternetRadioStationsForServersSettled( + serverIds: string[], +): Promise { + 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 { @@ -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 { + const params: Record = { 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 { @@ -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 { + const params: Record = { id, name, streamUrl }; + if (homepageUrl) params.homepageUrl = homepageUrl; + await apiForServer(serverId, 'updateInternetRadioStation.view', params); +} + export async function deleteInternetRadioStation(id: string): Promise { await api('deleteInternetRadioStation.view', { id }); } +export async function deleteInternetRadioStationForServer(serverId: string, id: string): Promise { + await apiForServer(serverId, 'deleteInternetRadioStation.view', { id }); +} + export async function uploadRadioCoverArt(id: string, file: File): Promise { - // 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 { + 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 { - // 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 { + 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 { - 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 { + 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); } diff --git a/src/lib/api/subsonicTypes.ts b/src/lib/api/subsonicTypes.ts index 37ad86d4..1fc9d425 100644 --- a/src/lib/api/subsonicTypes.ts +++ b/src/lib/api/subsonicTypes.ts @@ -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; diff --git a/src/lib/hooks/useNavidromeAdminRole.test.ts b/src/lib/hooks/useNavidromeAdminRole.test.ts index 355a9cfc..c294f8ac 100644 --- a/src/lib/hooks/useNavidromeAdminRole.test.ts +++ b/src/lib/hooks/useNavidromeAdminRole.test.ts @@ -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', () => { diff --git a/src/lib/hooks/useNavidromeAdminRole.ts b/src/lib/hooks/useNavidromeAdminRole.ts index d95705e9..84d0dede 100644 --- a/src/lib/hooks/useNavidromeAdminRole.ts +++ b/src/lib/hooks/useNavidromeAdminRole.ts @@ -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 { + 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; + }>({ 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('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'; }