From 76042ba1730b6c7b542aa9276c129cafba28bf29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 10 Jun 2025 17:22:13 -0400 Subject: [PATCH] feat(ui): add Now Playing panel for admins (#4209) * feat(ui): add Now Playing panel and integrate now playing count updates Signed-off-by: Deluan * fix: check return value in test to satisfy linter * fix: format React code with prettier * fix: resolve race condition in play tracker test * fix: log error when fetching now playing data fails Signed-off-by: Deluan * feat(ui): refactor Now Playing panel with new components and error handling Signed-off-by: Deluan * fix(ui): adjust padding and height in Now Playing panel for improved layout Signed-off-by: Deluan * fix(cache): add automatic cleanup to prevent goroutine leak on cache garbage collection Signed-off-by: Deluan --------- Signed-off-by: Deluan --- core/scrobbler/play_tracker.go | 6 + core/scrobbler/play_tracker_test.go | 48 +++- resources/i18n/pt-br.json | 5 + server/events/events.go | 5 + ui/src/actions/serverEvents.js | 6 + ui/src/eventStream.js | 1 + ui/src/i18n/en.json | 5 + ui/src/layout/AppBar.jsx | 4 + ui/src/layout/NowPlayingPanel.jsx | 338 ++++++++++++++++++++++++ ui/src/layout/NowPlayingPanel.test.jsx | 234 ++++++++++++++++ ui/src/reducers/activityReducer.js | 4 + ui/src/reducers/activityReducer.test.js | 16 +- ui/src/subsonic/index.js | 12 + ui/src/subsonic/index.test.js | 23 ++ utils/cache/simple_cache.go | 26 +- utils/cache/simple_cache_test.go | 14 + 16 files changed, 744 insertions(+), 3 deletions(-) create mode 100644 ui/src/layout/NowPlayingPanel.jsx create mode 100644 ui/src/layout/NowPlayingPanel.test.jsx diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index 53f397647..0f9b8c170 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -51,6 +51,10 @@ func GetPlayTracker(ds model.DataStore, broker events.Broker) PlayTracker { func newPlayTracker(ds model.DataStore, broker events.Broker) *playTracker { m := cache.NewSimpleCache[string, NowPlayingInfo]() p := &playTracker{ds: ds, playMap: m, broker: broker} + m.OnExpiration(func(_ string, _ NowPlayingInfo) { + ctx := events.BroadcastToAll(context.Background()) + broker.SendMessage(ctx, &events.NowPlayingCount{Count: m.Len()}) + }) p.scrobblers = make(map[string]Scrobbler) var enabled []string for name, constructor := range constructors { @@ -85,6 +89,8 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam ttl := time.Duration(int(mf.Duration)+5) * time.Second _ = p.playMap.AddWithTTL(playerId, info, ttl) + ctx = events.BroadcastToAll(ctx) + p.broker.SendMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) player, _ := request.PlayerFrom(ctx) if player.ScrobbleEnabled { p.dispatchNowPlaying(ctx, user.ID, mf) diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 0ff025f15..d540e6faa 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -3,6 +3,8 @@ package scrobbler import ( "context" "errors" + "net/http" + "sync" "time" "github.com/navidrome/navidrome/consts" @@ -19,6 +21,7 @@ var _ = Describe("PlayTracker", func() { var ctx context.Context var ds model.DataStore var tracker PlayTracker + var eventBroker *fakeEventBroker var track model.MediaFile var album model.Album var artist1 model.Artist @@ -37,7 +40,8 @@ var _ = Describe("PlayTracker", func() { Register("disabled", func(model.DataStore) Scrobbler { return nil }) - tracker = newPlayTracker(ds, events.GetBroker()) + eventBroker = &fakeEventBroker{} + tracker = newPlayTracker(ds, eventBroker) tracker.(*playTracker).scrobblers["fake"] = &fake // Bypass buffering for tests track = model.MediaFile{ @@ -99,6 +103,16 @@ var _ = Describe("PlayTracker", func() { Expect(err).ToNot(HaveOccurred()) Expect(fake.NowPlayingCalled).To(BeFalse()) }) + + It("sends event with count", func() { + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123") + Expect(err).ToNot(HaveOccurred()) + eventList := eventBroker.getEvents() + Expect(eventList).ToNot(BeEmpty()) + evt, ok := eventList[0].(*events.NowPlayingCount) + Expect(ok).To(BeTrue()) + Expect(evt.Count).To(Equal(1)) + }) }) Describe("GetNowPlaying", func() { @@ -127,6 +141,18 @@ var _ = Describe("PlayTracker", func() { }) }) + Describe("Expiration events", func() { + It("sends event when entry expires", func() { + info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"} + _ = tracker.(*playTracker).playMap.AddWithTTL("player-1", info, 10*time.Millisecond) + Eventually(func() int { return len(eventBroker.getEvents()) }).Should(BeNumerically(">", 0)) + eventList := eventBroker.getEvents() + evt, ok := eventList[len(eventList)-1].(*events.NowPlayingCount) + Expect(ok).To(BeTrue()) + Expect(evt.Count).To(Equal(0)) + }) + }) + Describe("Submit", func() { It("sends track to agent", func() { ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"}) @@ -243,3 +269,23 @@ func _p(id, name string, sortName ...string) model.Participant { } return p } + +type fakeEventBroker struct { + http.Handler + events []events.Event + mu sync.Mutex +} + +func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) { + f.mu.Lock() + defer f.mu.Unlock() + f.events = append(f.events, event) +} + +func (f *fakeEventBroker) getEvents() []events.Event { + f.mu.Lock() + defer f.mu.Unlock() + return f.events +} + +var _ events.Broker = (*fakeEventBroker)(nil) diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index 285a71523..126f8ffc8 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -537,6 +537,11 @@ "status": "Erro", "elapsedTime": "Duração" }, + "nowPlaying": { + "title": "Tocando agora", + "empty": "Nada tocando", + "minutesAgo": "%{smart_count} minuto atrás |||| %{smart_count} minutos atrás" + }, "help": { "title": "Teclas de atalho", "hotkeys": { diff --git a/server/events/events.go b/server/events/events.go index 73ff8eb5e..e8dcd81f0 100644 --- a/server/events/events.go +++ b/server/events/events.go @@ -63,6 +63,11 @@ type RefreshResource struct { resources map[string][]string } +type NowPlayingCount struct { + baseEvent + Count int `json:"count"` +} + func (rr *RefreshResource) With(resource string, ids ...string) *RefreshResource { if rr.resources == nil { rr.resources = make(map[string][]string) diff --git a/ui/src/actions/serverEvents.js b/ui/src/actions/serverEvents.js index 7d89c5feb..d1e55283a 100644 --- a/ui/src/actions/serverEvents.js +++ b/ui/src/actions/serverEvents.js @@ -1,6 +1,7 @@ export const EVENT_SCAN_STATUS = 'scanStatus' export const EVENT_SERVER_START = 'serverStart' export const EVENT_REFRESH_RESOURCE = 'refreshResource' +export const EVENT_NOW_PLAYING_COUNT = 'nowPlayingCount' export const processEvent = (type, data) => ({ type, @@ -11,6 +12,11 @@ export const scanStatusUpdate = (data) => ({ data: data, }) +export const nowPlayingCountUpdate = (data) => ({ + type: EVENT_NOW_PLAYING_COUNT, + data: data, +}) + export const serverDown = () => ({ type: EVENT_SERVER_START, data: {}, diff --git a/ui/src/eventStream.js b/ui/src/eventStream.js index 34463e19c..33a7f6c9e 100644 --- a/ui/src/eventStream.js +++ b/ui/src/eventStream.js @@ -33,6 +33,7 @@ const startEventStream = async (dispatchFn) => { throttledEventHandler(dispatchFn), ) newStream.addEventListener('refreshResource', eventHandler(dispatchFn)) + newStream.addEventListener('nowPlayingCount', eventHandler(dispatchFn)) newStream.addEventListener('keepAlive', eventHandler(dispatchFn)) newStream.onerror = (e) => { // eslint-disable-next-line no-console diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index b3f94ab42..8f90e6bdf 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -540,6 +540,11 @@ "status": "Scan Error", "elapsedTime": "Elapsed Time" }, + "nowPlaying": { + "title": "Now Playing", + "empty": "Nothing playing", + "minutesAgo": "%{smart_count} minute ago |||| %{smart_count} minutes ago" + }, "help": { "title": "Navidrome Hotkeys", "hotkeys": { diff --git a/ui/src/layout/AppBar.jsx b/ui/src/layout/AppBar.jsx index a8c36cd14..5690c4264 100644 --- a/ui/src/layout/AppBar.jsx +++ b/ui/src/layout/AppBar.jsx @@ -14,6 +14,7 @@ import { Dialogs } from '../dialogs/Dialogs' import { AboutDialog } from '../dialogs' import PersonalMenu from './PersonalMenu' import ActivityPanel from './ActivityPanel' +import NowPlayingPanel from './NowPlayingPanel' import UserMenu from './UserMenu' import config from '../config' @@ -119,6 +120,9 @@ const CustomUserMenu = ({ onClick, ...rest }) => { return ( <> + {config.devActivityPanel && permissions === 'admin' && ( + + )} {config.devActivityPanel && permissions === 'admin' && } diff --git a/ui/src/layout/NowPlayingPanel.jsx b/ui/src/layout/NowPlayingPanel.jsx new file mode 100644 index 000000000..7797c7733 --- /dev/null +++ b/ui/src/layout/NowPlayingPanel.jsx @@ -0,0 +1,338 @@ +import React, { useState, useEffect, useCallback } from 'react' +import PropTypes from 'prop-types' +import { useSelector, useDispatch } from 'react-redux' +import { useTranslate, Link, useNotify } from 'react-admin' +import { + Popover, + IconButton, + makeStyles, + Tooltip, + List, + ListItem, + ListItemText, + ListItemAvatar, + Avatar, + Badge, + Card, + CardContent, + Typography, + useTheme, + useMediaQuery, +} from '@material-ui/core' +import { FaRegCirclePlay } from 'react-icons/fa6' +import subsonic from '../subsonic' +import { useInterval } from '../common' +import { nowPlayingCountUpdate } from '../actions' +import config from '../config' + +const useStyles = makeStyles((theme) => ({ + button: { color: 'inherit' }, + list: { + width: '30em', + maxHeight: (props) => { + // Calculate height for up to 4 entries before scrolling + const entryHeight = 80 + const maxEntries = Math.min(props.entryCount || 0, 4) + return maxEntries > 0 ? `${maxEntries * entryHeight}px` : '12em' + }, + overflowY: 'auto', + padding: 0, + }, + card: { + padding: 0, + }, + cardContent: { + padding: `${theme.spacing(1)}px !important`, // Minimal padding, override default + '&:last-child': { + paddingBottom: `${theme.spacing(1)}px !important`, // Override Material-UI's last-child padding + }, + }, + listItem: { + paddingTop: theme.spacing(0.5), + paddingBottom: theme.spacing(0.5), + paddingLeft: theme.spacing(1), + paddingRight: theme.spacing(1), + }, + avatar: { + width: theme.spacing(6), + height: theme.spacing(6), + cursor: 'pointer', + '&:hover': { + opacity: 0.8, + }, + }, + badge: { + '& .MuiBadge-badge': { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + }, + }, + artistLink: { + cursor: 'pointer', + '&:hover': { + textDecoration: 'underline', + }, + }, + primaryText: { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + }, +})) + +// NowPlayingButton component - handles the button with badge +const NowPlayingButton = React.memo(({ count, onClick }) => { + const classes = useStyles() + const translate = useTranslate() + + return ( + + + + + + + + ) +}) + +NowPlayingButton.displayName = 'NowPlayingButton' + +NowPlayingButton.propTypes = { + count: PropTypes.number.isRequired, + onClick: PropTypes.func.isRequired, +} + +// NowPlayingItem component - individual list item +const NowPlayingItem = React.memo( + ({ nowPlayingEntry, onLinkClick, getArtistLink }) => { + const classes = useStyles() + const translate = useTranslate() + + return ( + + + + + + + + {nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId ? ( + + {nowPlayingEntry.albumArtist || nowPlayingEntry.artist} + + ) : ( + + {nowPlayingEntry.albumArtist || nowPlayingEntry.artist} + + )} +  - {nowPlayingEntry.title} + + } + secondary={`${nowPlayingEntry.username}${nowPlayingEntry.playerName ? ` (${nowPlayingEntry.playerName})` : ''} • ${translate('nowPlaying.minutesAgo', { smart_count: nowPlayingEntry.minutesAgo })}`} + /> + + ) + }, +) + +NowPlayingItem.displayName = 'NowPlayingItem' + +NowPlayingItem.propTypes = { + nowPlayingEntry: PropTypes.shape({ + playerId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + .isRequired, + albumId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + .isRequired, + albumArtistId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + artistId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + albumArtist: PropTypes.string, + artist: PropTypes.string, + title: PropTypes.string.isRequired, + username: PropTypes.string.isRequired, + playerName: PropTypes.string, + minutesAgo: PropTypes.number.isRequired, + album: PropTypes.string, + }).isRequired, + onLinkClick: PropTypes.func.isRequired, + getArtistLink: PropTypes.func.isRequired, +} + +// NowPlayingList component - handles the popover content +const NowPlayingList = React.memo( + ({ anchorEl, open, onClose, entries, onLinkClick, getArtistLink }) => { + const classes = useStyles({ entryCount: entries.length }) + const translate = useTranslate() + + return ( + + + + {entries.length === 0 ? ( + + {translate('nowPlaying.empty')} + + ) : ( + + {entries.map((nowPlayingEntry) => ( + + ))} + + )} + + + + ) + }, +) + +NowPlayingList.displayName = 'NowPlayingList' + +NowPlayingList.propTypes = { + anchorEl: PropTypes.object, + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + entries: PropTypes.arrayOf(PropTypes.object).isRequired, + onLinkClick: PropTypes.func.isRequired, + getArtistLink: PropTypes.func.isRequired, +} + +// Main NowPlayingPanel component +const NowPlayingPanel = () => { + const dispatch = useDispatch() + const count = useSelector((state) => state.activity.nowPlayingCount) + const translate = useTranslate() + const notify = useNotify() + const theme = useTheme() + const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm')) + + const [anchorEl, setAnchorEl] = useState(null) + const [entries, setEntries] = useState([]) + const open = Boolean(anchorEl) + + const handleMenuOpen = useCallback((event) => { + setAnchorEl(event.currentTarget) + }, []) + + const handleMenuClose = useCallback(() => { + setAnchorEl(null) + }, []) + + // Close panel when link is clicked on small screens + const handleLinkClick = useCallback(() => { + if (isSmallScreen) { + handleMenuClose() + } + }, [isSmallScreen, handleMenuClose]) + + const getArtistLink = useCallback((artistId) => { + if (!artistId) return null + return config.devShowArtistPage && artistId !== config.variousArtistsId + ? `/artist/${artistId}/show` + : `/album?filter={"artist_id":"${artistId}"}&order=ASC&sort=max_year&displayedFilters={"compilation":true}&perPage=15` + }, []) + + const fetchList = useCallback( + () => + subsonic + .getNowPlaying() + .then((resp) => resp.json['subsonic-response']) + .then((data) => { + if (data.status === 'ok') { + const nowPlayingEntries = data.nowPlaying?.entry || [] + setEntries(nowPlayingEntries) + // Also update the count in Redux store + dispatch(nowPlayingCountUpdate({ count: nowPlayingEntries.length })) + } else { + throw new Error( + data.error?.message || 'Failed to fetch now playing data', + ) + } + }) + .catch((error) => { + notify('ra.page.error', 'warning', { + messageArgs: { error: error.message || 'Unknown error' }, + }) + }), + [dispatch, notify], + ) + + // Initialize count and entries on mount + useEffect(() => { + fetchList() + }, [fetchList]) + + // Refresh when count changes from WebSocket events (if panel is open) + useEffect(() => { + if (open) fetchList() + }, [count, open, fetchList]) + + useInterval( + () => { + if (open) fetchList() + }, + open ? 10000 : null, + ) + + return ( +
+ + +
+ ) +} + +NowPlayingPanel.propTypes = {} + +export default NowPlayingPanel diff --git a/ui/src/layout/NowPlayingPanel.test.jsx b/ui/src/layout/NowPlayingPanel.test.jsx new file mode 100644 index 000000000..6cc332fcd --- /dev/null +++ b/ui/src/layout/NowPlayingPanel.test.jsx @@ -0,0 +1,234 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { describe, it, beforeEach, vi } from 'vitest' +import { Provider } from 'react-redux' +import { createStore, combineReducers } from 'redux' +import { activityReducer } from '../reducers' +import NowPlayingPanel from './NowPlayingPanel' +import subsonic from '../subsonic' + +vi.mock('../subsonic', () => ({ + default: { + getNowPlaying: vi.fn(), + getAvatarUrl: vi.fn(() => '/avatar'), + getCoverArtUrl: vi.fn(() => '/cover'), + }, +})) + +// Create a mock for useMediaQuery +const mockUseMediaQuery = vi.fn() + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + const redux = await import('react-redux') + return { + ...actual, + useTranslate: () => (x) => x, + useSelector: redux.useSelector, + useDispatch: redux.useDispatch, + Link: ({ to, children, onClick, ...props }) => ( + { + e.preventDefault() // Prevent navigation in tests + if (onClick) onClick(e) + }} + {...props} + > + {children} + + ), + } +}) + +// Mock the specific Material-UI hooks we need +vi.mock('@material-ui/core/useMediaQuery', () => ({ + default: () => mockUseMediaQuery(), +})) + +vi.mock('@material-ui/core/styles/useTheme', () => ({ + default: () => ({ + breakpoints: { + down: () => '(max-width:959.95px)', // Mock breakpoint string + }, + }), +})) + +describe('', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseMediaQuery.mockReturnValue(false) // Default to large screen + + subsonic.getNowPlaying.mockResolvedValue({ + json: { + 'subsonic-response': { + status: 'ok', + nowPlaying: { + entry: [ + { + playerId: 1, + username: 'u1', + playerName: 'Chrome Browser', + title: 'Song', + albumArtist: 'Artist', + albumId: 'album1', + albumArtistId: 'artist1', + minutesAgo: 2, + }, + ], + }, + }, + }, + }) + }) + + it('fetches and displays entries when opened', async () => { + const store = createStore(combineReducers({ activity: activityReducer }), { + activity: { nowPlayingCount: 1 }, + }) + render( + + + , + ) + + // Wait for initial fetch to complete + await waitFor(() => { + expect(subsonic.getNowPlaying).toHaveBeenCalled() + }) + + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText('Artist')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Artist' })).toHaveAttribute( + 'href', + '/artist/artist1/show', + ) + }) + }) + + it('displays player name after username', async () => { + const store = createStore(combineReducers({ activity: activityReducer }), { + activity: { nowPlayingCount: 1 }, + }) + render( + + + , + ) + + // Wait for initial fetch to complete + await waitFor(() => { + expect(subsonic.getNowPlaying).toHaveBeenCalled() + }) + + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect( + screen.getByText('u1 (Chrome Browser) • nowPlaying.minutesAgo'), + ).toBeInTheDocument() + }) + }) + + it('handles entries without player name', async () => { + subsonic.getNowPlaying.mockResolvedValueOnce({ + json: { + 'subsonic-response': { + status: 'ok', + nowPlaying: { + entry: [ + { + playerId: 1, + username: 'u1', + title: 'Song', + albumArtist: 'Artist', + albumId: 'album1', + albumArtistId: 'artist1', + minutesAgo: 2, + }, + ], + }, + }, + }, + }) + + const store = createStore(combineReducers({ activity: activityReducer }), { + activity: { nowPlayingCount: 1 }, + }) + render( + + + , + ) + + // Wait for initial fetch to complete + await waitFor(() => { + expect(subsonic.getNowPlaying).toHaveBeenCalled() + }) + + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText('u1 • nowPlaying.minutesAgo')).toBeInTheDocument() + }) + }) + + it('shows empty message when no entries', async () => { + subsonic.getNowPlaying.mockResolvedValueOnce({ + json: { + 'subsonic-response': { status: 'ok', nowPlaying: { entry: [] } }, + }, + }) + const store = createStore(combineReducers({ activity: activityReducer }), { + activity: { nowPlayingCount: 0 }, + }) + render( + + + , + ) + + // Wait for initial fetch + await waitFor(() => { + expect(subsonic.getNowPlaying).toHaveBeenCalled() + }) + + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText('nowPlaying.empty')).toBeInTheDocument() + }) + }) + + it('does not close panel when artist link is clicked on large screens', async () => { + mockUseMediaQuery.mockReturnValue(false) // Simulate large screen + + const store = createStore(combineReducers({ activity: activityReducer }), { + activity: { nowPlayingCount: 1 }, + }) + render( + + + , + ) + + // Wait for initial fetch to complete + await waitFor(() => { + expect(subsonic.getNowPlaying).toHaveBeenCalled() + }) + + // Open the panel + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText('Artist')).toBeInTheDocument() + }) + + // Check that the popover is open + expect(screen.getByRole('presentation')).toBeInTheDocument() + + // Click the artist link + fireEvent.click(screen.getByRole('link', { name: 'Artist' })) + + // Panel should remain open (popover should still be in document) + expect(screen.getByRole('presentation')).toBeInTheDocument() + expect(screen.getByText('Artist')).toBeInTheDocument() + }) +}) diff --git a/ui/src/reducers/activityReducer.js b/ui/src/reducers/activityReducer.js index 2b6d2741c..874ebb534 100644 --- a/ui/src/reducers/activityReducer.js +++ b/ui/src/reducers/activityReducer.js @@ -2,6 +2,7 @@ import { EVENT_REFRESH_RESOURCE, EVENT_SCAN_STATUS, EVENT_SERVER_START, + EVENT_NOW_PLAYING_COUNT, } from '../actions' import config from '../config' @@ -14,6 +15,7 @@ const initialState = { elapsedTime: 0, }, serverStart: { version: config.version }, + nowPlayingCount: 0, } export const activityReducer = (previousState = initialState, payload) => { @@ -40,6 +42,8 @@ export const activityReducer = (previousState = initialState, payload) => { resources: data, }, } + case EVENT_NOW_PLAYING_COUNT: + return { ...previousState, nowPlayingCount: data.count } default: return previousState } diff --git a/ui/src/reducers/activityReducer.test.js b/ui/src/reducers/activityReducer.test.js index a1389e3d2..7c1d8b08f 100644 --- a/ui/src/reducers/activityReducer.test.js +++ b/ui/src/reducers/activityReducer.test.js @@ -1,5 +1,9 @@ import { activityReducer } from './activityReducer' -import { EVENT_SCAN_STATUS, EVENT_SERVER_START } from '../actions' +import { + EVENT_SCAN_STATUS, + EVENT_SERVER_START, + EVENT_NOW_PLAYING_COUNT, +} from '../actions' import config from '../config' describe('activityReducer', () => { @@ -12,6 +16,7 @@ describe('activityReducer', () => { elapsedTime: 0, }, serverStart: { version: config.version }, + nowPlayingCount: 0, } it('returns the initial state when no action is specified', () => { @@ -116,4 +121,13 @@ describe('activityReducer', () => { startTime: Date.parse('2023-01-01T00:00:00Z'), }) }) + + it('handles EVENT_NOW_PLAYING_COUNT', () => { + const action = { + type: EVENT_NOW_PLAYING_COUNT, + data: { count: 5 }, + } + const newState = activityReducer(initialState, action) + expect(newState.nowPlayingCount).toEqual(5) + }) }) diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index f42ca24e3..806ac8a9b 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -54,6 +54,16 @@ const startScan = (options) => httpClient(url('startScan', null, options)) const getScanStatus = () => httpClient(url('getScanStatus')) +const getNowPlaying = () => httpClient(url('getNowPlaying')) + +const getAvatarUrl = (username, size) => + baseUrl( + url('getAvatar', null, { + username, + ...(size && { size }), + }), + ) + const getCoverArtUrl = (record, size, square) => { const options = { ...(record.updatedAt && { _: record.updatedAt }), @@ -110,7 +120,9 @@ export default { setRating, startScan, getScanStatus, + getNowPlaying, getCoverArtUrl, + getAvatarUrl, streamUrl, getAlbumInfo, getArtistInfo, diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js index 6b902dfb1..1e0fbeaa6 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -104,3 +104,26 @@ describe('getCoverArtUrl', () => { expect(url).not.toContain('_=') }) }) + +describe('getAvatarUrl', () => { + beforeEach(() => { + // Mock localStorage values required by subsonic + const localStorageMock = { + getItem: vi.fn((key) => { + const values = { + username: 'testuser', + 'subsonic-token': 'testtoken', + 'subsonic-salt': 'testsalt', + } + return values[key] || null + }), + } + Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + }) + + it('should include username parameter', () => { + const url = subsonic.getAvatarUrl('john') + expect(url).toContain('getAvatar') + expect(url).toContain('username=john') + }) +}) diff --git a/utils/cache/simple_cache.go b/utils/cache/simple_cache.go index 182d1d12a..cac41be7b 100644 --- a/utils/cache/simple_cache.go +++ b/utils/cache/simple_cache.go @@ -1,8 +1,10 @@ package cache import ( + "context" "errors" "fmt" + "runtime" "sync/atomic" "time" @@ -17,6 +19,8 @@ type SimpleCache[K comparable, V any] interface { GetWithLoader(key K, loader func(key K) (V, time.Duration, error)) (V, error) Keys() []K Values() []V + Len() int + OnExpiration(fn func(K, V)) func() } type Options struct { @@ -39,9 +43,17 @@ func NewSimpleCache[K comparable, V any](options ...Options) SimpleCache[K, V] { } c := ttlcache.New[K, V](opts...) - return &simpleCache[K, V]{ + cache := &simpleCache[K, V]{ data: c, } + go cache.data.Start() + + // Automatic cleanup to prevent goroutine leak when cache is garbage collected + runtime.AddCleanup(cache, func(ttlCache *ttlcache.Cache[K, V]) { + ttlCache.Stop() + }, cache.data) + + return cache } const evictionTimeout = 1 * time.Hour @@ -127,3 +139,15 @@ func (c *simpleCache[K, V]) Values() []V { }) return res } + +func (c *simpleCache[K, V]) Len() int { + return c.data.Len() +} + +func (c *simpleCache[K, V]) OnExpiration(fn func(K, V)) func() { + return c.data.OnEviction(func(_ context.Context, reason ttlcache.EvictionReason, item *ttlcache.Item[K, V]) { + if reason == ttlcache.EvictionReasonExpired { + fn(item.Key(), item.Value()) + } + }) +} diff --git a/utils/cache/simple_cache_test.go b/utils/cache/simple_cache_test.go index 88dab5e07..45ba2c966 100644 --- a/utils/cache/simple_cache_test.go +++ b/utils/cache/simple_cache_test.go @@ -143,5 +143,19 @@ var _ = Describe("SimpleCache", func() { Expect(cache.Get("key0")).To(Equal("value0")) }) }) + + Describe("OnExpiration", func() { + It("should call callback when item expires", func() { + cache = NewSimpleCache[string, string]() + expired := make(chan struct{}) + cache.OnExpiration(func(k, v string) { close(expired) }) + Expect(cache.AddWithTTL("key", "value", 10*time.Millisecond)).To(Succeed()) + select { + case <-expired: + case <-time.After(100 * time.Millisecond): + Fail("expiration callback not called") + } + }) + }) }) })