diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go
index 53f39764..0f9b8c17 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 0ff025f1..d540e6fa 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 285a7152..126f8ffc 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 73ff8eb5..e8dcd81f 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 7d89c5fe..d1e55283 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 34463e19..33a7f6c9 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 b3f94ab4..8f90e6bd 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 a8c36cd1..5690c426 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 00000000..7797c773
--- /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 00000000..6cc332fc
--- /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 2b6d2741..874ebb53 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 a1389e3d..7c1d8b08 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 f42ca24e..806ac8a9 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 6b902dfb..1e0fbeaa 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 182d1d12..cac41be7 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 88dab5e0..45ba2c96 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")
+ }
+ })
+ })
})
})