diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index c7a8937fc..6f09f5349 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -138,4 +138,8 @@ func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) { f.Events = append(f.Events, event) } +func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) { + f.Events = append(f.Events, event) +} + var _ events.Broker = (*fakeEventBroker)(nil) diff --git a/ui/src/common/LibrarySelector.jsx b/ui/src/common/LibrarySelector.jsx index 671bc8a84..341552015 100644 --- a/ui/src/common/LibrarySelector.jsx +++ b/ui/src/common/LibrarySelector.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useCallback } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useDataProvider, useTranslate, useRefresh } from 'react-admin' import { @@ -16,6 +16,7 @@ import { } from '@material-ui/core' import { ExpandMore, ExpandLess, LibraryMusic } from '@material-ui/icons' import { setSelectedLibraries, setUserLibraries } from '../actions' +import { useRefreshOnEvents } from './useRefreshOnEvents' const useStyles = makeStyles((theme) => ({ root: { @@ -74,76 +75,39 @@ const LibrarySelector = () => { const refresh = useRefresh() const [anchorEl, setAnchorEl] = useState(null) const [open, setOpen] = useState(false) - const [lastRefreshTime, setLastRefreshTime] = useState(Date.now()) const { userLibraries, selectedLibraries } = useSelector( (state) => state.library, ) - // Watch for refresh events to reload libraries - const refreshData = useSelector( - (state) => state.activity?.refresh || { lastReceived: lastRefreshTime }, - ) - - // Load user's libraries when component mounts or when refresh events occur - useEffect(() => { - const loadUserLibraries = async () => { - // Get userId from localStorage (not from identity which returns username) - const userId = localStorage.getItem('userId') - if (userId) { - try { - const { data } = await dataProvider.getOne('user', { id: userId }) - const libraries = data.libraries || [] - dispatch(setUserLibraries(libraries)) - } catch (error) { - // eslint-disable-next-line no-console - console.warn( - 'Could not load user libraries (this may be expected for non-admin users):', - error, - ) - } + // Load user's libraries when component mounts + const loadUserLibraries = useCallback(async () => { + const userId = localStorage.getItem('userId') + if (userId) { + try { + const { data } = await dataProvider.getOne('user', { id: userId }) + const libraries = data.libraries || [] + dispatch(setUserLibraries(libraries)) + } catch (error) { + // eslint-disable-next-line no-console + console.warn( + 'Could not load user libraries (this may be expected for non-admin users):', + error, + ) } } - - loadUserLibraries() }, [dataProvider, dispatch]) - // Watch for refresh events and reload libraries when they occur + // Initial load useEffect(() => { - const { resources, lastReceived } = refreshData - - if (lastReceived <= lastRefreshTime) { - return - } + loadUserLibraries() + }, [loadUserLibraries]) - const shouldReload = resources && - (resources['*'] === '*' || - resources.library || - resources.user) - - if (shouldReload) { - setLastRefreshTime(lastReceived) - - const loadUserLibraries = async () => { - const userId = localStorage.getItem('userId') - if (userId) { - try { - const { data } = await dataProvider.getOne('user', { id: userId }) - const libraries = data.libraries || [] - dispatch(setUserLibraries(libraries)) - } catch (error) { - // eslint-disable-next-line no-console - console.warn( - 'Could not reload user libraries after refresh event:', - error, - ) - } - } - } - - loadUserLibraries() - } - }, [dataProvider, dispatch, refreshData, lastRefreshTime]) + // Reload user libraries when library changes occur + useRefreshOnEvents({ + events: ['library'], + onRefresh: loadUserLibraries, + }) // Don't render if user has no libraries or only has one library if (!userLibraries.length || userLibraries.length === 1) { diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 1a43047c1..f64d4fe0c 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -27,6 +27,7 @@ export * from './useAlbumsPerPage' export * from './useGetHandleArtistClick' export * from './useInterval' export * from './useResourceRefresh' +export * from './useRefreshOnEvents' export * from './useToggleLove' export * from './useTraceUpdate' export * from './Writable' diff --git a/ui/src/common/useRefreshOnEvents.jsx b/ui/src/common/useRefreshOnEvents.jsx new file mode 100644 index 000000000..b5f1b1ede --- /dev/null +++ b/ui/src/common/useRefreshOnEvents.jsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from 'react' +import { useSelector } from 'react-redux' + +/** + * A reusable hook for triggering custom reload logic when specific SSE events occur. + * + * This hook is ideal when: + * - Your component displays derived/related data that isn't directly managed by react-admin + * - You need custom loading logic that goes beyond simple dataProvider.getMany() calls + * - Your data comes from non-standard endpoints or requires special processing + * - You want to reload parent/related resources when child resources change + * + * @param {Object} options - Configuration options + * @param {Array} options.events - Array of event types to listen for (e.g., ['library', 'user', '*']) + * @param {Function} options.onRefresh - Async function to call when events occur. + * Should be wrapped in useCallback with appropriate dependencies to avoid unnecessary re-renders. + * + * @example + * // Example 1: LibrarySelector - Reload user data when library changes + * const loadUserLibraries = useCallback(async () => { + * const userId = localStorage.getItem('userId') + * if (userId) { + * const { data } = await dataProvider.getOne('user', { id: userId }) + * dispatch(setUserLibraries(data.libraries || [])) + * } + * }, [dataProvider, dispatch]) + * + * useRefreshOnEvents({ + * events: ['library', 'user'], + * onRefresh: loadUserLibraries + * }) + * + * @example + * // Example 2: Statistics Dashboard - Reload stats when any music data changes + * const loadStats = useCallback(async () => { + * const stats = await dataProvider.customRequest('GET', 'stats') + * setDashboardStats(stats) + * }, [dataProvider, setDashboardStats]) + * + * useRefreshOnEvents({ + * events: ['album', 'song', 'artist'], + * onRefresh: loadStats + * }) + * + * @example + * // Example 3: Permission-based UI - Reload permissions when user changes + * const loadPermissions = useCallback(async () => { + * const authData = await authProvider.getPermissions() + * setUserPermissions(authData) + * }, [authProvider, setUserPermissions]) + * + * useRefreshOnEvents({ + * events: ['user'], + * onRefresh: loadPermissions + * }) + * + * @example + * // Example 4: Listen to all events (use sparingly) + * const reloadAll = useCallback(async () => { + * // This will trigger on ANY refresh event + * await reloadEverything() + * }, [reloadEverything]) + * + * useRefreshOnEvents({ + * events: ['*'], + * onRefresh: reloadAll + * }) + */ +export const useRefreshOnEvents = ({ events, onRefresh }) => { + const [lastRefreshTime, setLastRefreshTime] = useState(Date.now()) + + const refreshData = useSelector( + (state) => state.activity?.refresh || { lastReceived: lastRefreshTime }, + ) + + useEffect(() => { + const { resources, lastReceived } = refreshData + + // Only process if we have new events + if (lastReceived <= lastRefreshTime) { + return + } + + // Check if any of the events we're interested in occurred + const shouldRefresh = + resources && + // Global refresh event + (resources['*'] === '*' || + // Check for specific events we're listening to + events.some((eventType) => { + if (eventType === '*') { + return true // Listen to all events + } + return resources[eventType] // Check if this specific event occurred + })) + + if (shouldRefresh) { + setLastRefreshTime(lastReceived) + + // Call the custom refresh function + if (onRefresh) { + onRefresh().catch((error) => { + // eslint-disable-next-line no-console + console.warn('Error in useRefreshOnEvents onRefresh callback:', error) + }) + } + } + }, [refreshData, lastRefreshTime, events, onRefresh]) +} diff --git a/ui/src/common/useRefreshOnEvents.test.js b/ui/src/common/useRefreshOnEvents.test.js new file mode 100644 index 000000000..2306cd3c9 --- /dev/null +++ b/ui/src/common/useRefreshOnEvents.test.js @@ -0,0 +1,233 @@ +import { vi } from 'vitest' +import * as React from 'react' +import * as Redux from 'react-redux' +import { useRefreshOnEvents } from './useRefreshOnEvents' + +vi.mock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useState: vi.fn(), + useEffect: vi.fn(), + } +}) + +vi.mock('react-redux', async () => { + const actual = await vi.importActual('react-redux') + return { + ...actual, + useSelector: vi.fn(), + } +}) + +describe('useRefreshOnEvents', () => { + const setState = vi.fn() + const useStateMock = (initState) => [initState, setState] + const onRefresh = vi.fn().mockResolvedValue() + let lastTime + let mockUseEffect + + beforeEach(() => { + vi.spyOn(React, 'useState').mockImplementation(useStateMock) + mockUseEffect = vi.spyOn(React, 'useEffect') + lastTime = new Date(new Date().valueOf() + 1000) + onRefresh.mockClear() + setState.mockClear() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('stores last time checked, to avoid redundant runs', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { library: ['lib-1'] }, // Need some resources to trigger the update + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + // Mock useEffect to immediately call the effect callback + mockUseEffect.mockImplementation((callback) => callback()) + + useRefreshOnEvents({ + events: ['library'], + onRefresh, + }) + + expect(setState).toHaveBeenCalledWith(lastTime) + }) + + it("does not run again if lastTime didn't change", () => { + vi.spyOn(React, 'useState').mockImplementation(() => [lastTime, setState]) + const useSelectorMock = () => ({ lastReceived: lastTime }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + // Mock useEffect to immediately call the effect callback + mockUseEffect.mockImplementation((callback) => callback()) + + useRefreshOnEvents({ + events: ['library'], + onRefresh, + }) + + expect(setState).not.toHaveBeenCalled() + expect(onRefresh).not.toHaveBeenCalled() + }) + + describe('Event listening and refresh triggering', () => { + beforeEach(() => { + // Mock useEffect to immediately call the effect callback + mockUseEffect.mockImplementation((callback) => callback()) + }) + + it('triggers refresh when a watched event occurs', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { library: ['lib-1', 'lib-2'] }, + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + useRefreshOnEvents({ + events: ['library'], + onRefresh, + }) + + expect(onRefresh).toHaveBeenCalledTimes(1) + expect(setState).toHaveBeenCalledWith(lastTime) + }) + + it('triggers refresh when multiple watched events occur', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { + library: ['lib-1'], + user: ['user-1'], + album: ['album-1'], // This shouldn't trigger since it's not watched + }, + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + useRefreshOnEvents({ + events: ['library', 'user'], + onRefresh, + }) + + expect(onRefresh).toHaveBeenCalledTimes(1) + expect(setState).toHaveBeenCalledWith(lastTime) + }) + + it('does not trigger refresh when unwatched events occur', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { album: ['album-1'], song: ['song-1'] }, + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + useRefreshOnEvents({ + events: ['library', 'user'], + onRefresh, + }) + + expect(onRefresh).not.toHaveBeenCalled() + expect(setState).not.toHaveBeenCalled() + }) + + it('triggers refresh on global refresh event', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { '*': '*' }, + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + useRefreshOnEvents({ + events: ['library'], + onRefresh, + }) + + expect(onRefresh).toHaveBeenCalledTimes(1) + expect(setState).toHaveBeenCalledWith(lastTime) + }) + + it('triggers refresh when listening to all events with "*"', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { song: ['song-1'] }, + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + useRefreshOnEvents({ + events: ['*'], + onRefresh, + }) + + expect(onRefresh).toHaveBeenCalledTimes(1) + expect(setState).toHaveBeenCalledWith(lastTime) + }) + + it('handles empty events array gracefully', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { library: ['lib-1'] }, + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + useRefreshOnEvents({ + events: [], + onRefresh, + }) + + expect(onRefresh).not.toHaveBeenCalled() + expect(setState).not.toHaveBeenCalled() + }) + + it('handles missing onRefresh function gracefully', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { library: ['lib-1'] }, + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + expect(() => { + useRefreshOnEvents({ + events: ['library'], + // onRefresh is undefined + }) + }).not.toThrow() + + expect(setState).toHaveBeenCalledWith(lastTime) + }) + + it('handles onRefresh errors gracefully', async () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}) + const failingRefresh = vi + .fn() + .mockRejectedValue(new Error('Refresh failed')) + + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { library: ['lib-1'] }, + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + useRefreshOnEvents({ + events: ['library'], + onRefresh: failingRefresh, + }) + + expect(failingRefresh).toHaveBeenCalledTimes(1) + expect(setState).toHaveBeenCalledWith(lastTime) + + // Wait for the promise to be rejected and handled + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Error in useRefreshOnEvents onRefresh callback:', + expect.any(Error), + ) + + consoleWarnSpy.mockRestore() + }) + }) +}) diff --git a/ui/src/common/useResourceRefresh.jsx b/ui/src/common/useResourceRefresh.jsx index d9f6aee52..eabff6f92 100644 --- a/ui/src/common/useResourceRefresh.jsx +++ b/ui/src/common/useResourceRefresh.jsx @@ -2,6 +2,67 @@ import { useSelector } from 'react-redux' import { useState } from 'react' import { useRefresh, useDataProvider } from 'react-admin' +/** + * A hook that automatically refreshes react-admin managed resources when refresh events are received via SSE. + * + * This hook is designed for components that display react-admin managed resources (like lists, shows, edits) + * and need to stay in sync when those resources are modified elsewhere in the application. + * + * **When to use this hook:** + * - Your component displays react-admin resources (albums, songs, artists, playlists, etc.) + * - You want automatic refresh when those resources are created/updated/deleted + * - Your data comes from standard dataProvider.getMany() calls + * - You're using react-admin's data management (queries, mutations, caching) + * + * **When NOT to use this hook:** + * - Your component displays derived/custom data not directly managed by react-admin + * - You need custom reload logic beyond dataProvider.getMany() + * - Your data comes from non-standard endpoints + * - Use `useRefreshOnEvents` instead for these scenarios + * + * @param {...string} visibleResources - Resource names to watch for changes. + * If no resources specified, watches all resources. + * If '*' is included in resources, triggers full page refresh. + * + * @example + * // Example 1: Album list - refresh when albums change + * const AlbumList = () => { + * useResourceRefresh('album') + * return ... + * } + * + * @example + * // Example 2: Album show page - refresh when album or its songs change + * const AlbumShow = () => { + * useResourceRefresh('album', 'song') + * return ... + * } + * + * @example + * // Example 3: Dashboard - refresh when any resource changes + * const Dashboard = () => { + * useResourceRefresh() // No parameters = watch all resources + * return
...
+ * } + * + * @example + * // Example 4: Library management page - watch library resources + * const LibraryList = () => { + * useResourceRefresh('library') + * return ... + * } + * + * **How it works:** + * - Listens to refresh events from the SSE connection + * - When events arrive, checks if they match the specified visible resources + * - For specific resource IDs: calls dataProvider.getMany(resource, {ids: [...]}) + * - For global refreshes: calls refresh() to reload the entire page + * - Uses react-admin's built-in data management and caching + * + * **Event format expected:** + * - Global refresh: { '*': '*' } or { someResource: ['*'] } + * - Specific resources: { album: ['id1', 'id2'], song: ['id3'] } + */ export const useResourceRefresh = (...visibleResources) => { const [lastTime, setLastTime] = useState(Date.now()) const refresh = useRefresh() diff --git a/ui/src/library/LibraryList.jsx b/ui/src/library/LibraryList.jsx index 0933913f2..71adc117e 100644 --- a/ui/src/library/LibraryList.jsx +++ b/ui/src/library/LibraryList.jsx @@ -18,7 +18,7 @@ const LibraryFilter = (props) => ( const LibraryList = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) - useResourceRefresh() + useResourceRefresh('library') return (