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 <deluan@navidrome.org> * 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 <deluan@navidrome.org> * feat(ui): refactor Now Playing panel with new components and error handling Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): adjust padding and height in Now Playing panel for improved layout Signed-off-by: Deluan <deluan@navidrome.org> * fix(cache): add automatic cleanup to prevent goroutine leak on cache garbage collection Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -51,6 +51,10 @@ func GetPlayTracker(ds model.DataStore, broker events.Broker) PlayTracker {
|
|||||||
func newPlayTracker(ds model.DataStore, broker events.Broker) *playTracker {
|
func newPlayTracker(ds model.DataStore, broker events.Broker) *playTracker {
|
||||||
m := cache.NewSimpleCache[string, NowPlayingInfo]()
|
m := cache.NewSimpleCache[string, NowPlayingInfo]()
|
||||||
p := &playTracker{ds: ds, playMap: m, broker: broker}
|
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)
|
p.scrobblers = make(map[string]Scrobbler)
|
||||||
var enabled []string
|
var enabled []string
|
||||||
for name, constructor := range constructors {
|
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
|
ttl := time.Duration(int(mf.Duration)+5) * time.Second
|
||||||
_ = p.playMap.AddWithTTL(playerId, info, ttl)
|
_ = p.playMap.AddWithTTL(playerId, info, ttl)
|
||||||
|
ctx = events.BroadcastToAll(ctx)
|
||||||
|
p.broker.SendMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
|
||||||
player, _ := request.PlayerFrom(ctx)
|
player, _ := request.PlayerFrom(ctx)
|
||||||
if player.ScrobbleEnabled {
|
if player.ScrobbleEnabled {
|
||||||
p.dispatchNowPlaying(ctx, user.ID, mf)
|
p.dispatchNowPlaying(ctx, user.ID, mf)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package scrobbler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
@@ -19,6 +21,7 @@ var _ = Describe("PlayTracker", func() {
|
|||||||
var ctx context.Context
|
var ctx context.Context
|
||||||
var ds model.DataStore
|
var ds model.DataStore
|
||||||
var tracker PlayTracker
|
var tracker PlayTracker
|
||||||
|
var eventBroker *fakeEventBroker
|
||||||
var track model.MediaFile
|
var track model.MediaFile
|
||||||
var album model.Album
|
var album model.Album
|
||||||
var artist1 model.Artist
|
var artist1 model.Artist
|
||||||
@@ -37,7 +40,8 @@ var _ = Describe("PlayTracker", func() {
|
|||||||
Register("disabled", func(model.DataStore) Scrobbler {
|
Register("disabled", func(model.DataStore) Scrobbler {
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
tracker = newPlayTracker(ds, events.GetBroker())
|
eventBroker = &fakeEventBroker{}
|
||||||
|
tracker = newPlayTracker(ds, eventBroker)
|
||||||
tracker.(*playTracker).scrobblers["fake"] = &fake // Bypass buffering for tests
|
tracker.(*playTracker).scrobblers["fake"] = &fake // Bypass buffering for tests
|
||||||
|
|
||||||
track = model.MediaFile{
|
track = model.MediaFile{
|
||||||
@@ -99,6 +103,16 @@ var _ = Describe("PlayTracker", func() {
|
|||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(fake.NowPlayingCalled).To(BeFalse())
|
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() {
|
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() {
|
Describe("Submit", func() {
|
||||||
It("sends track to agent", func() {
|
It("sends track to agent", func() {
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"})
|
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
|
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)
|
||||||
|
|||||||
@@ -537,6 +537,11 @@
|
|||||||
"status": "Erro",
|
"status": "Erro",
|
||||||
"elapsedTime": "Duração"
|
"elapsedTime": "Duração"
|
||||||
},
|
},
|
||||||
|
"nowPlaying": {
|
||||||
|
"title": "Tocando agora",
|
||||||
|
"empty": "Nada tocando",
|
||||||
|
"minutesAgo": "%{smart_count} minuto atrás |||| %{smart_count} minutos atrás"
|
||||||
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "Teclas de atalho",
|
"title": "Teclas de atalho",
|
||||||
"hotkeys": {
|
"hotkeys": {
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ type RefreshResource struct {
|
|||||||
resources map[string][]string
|
resources map[string][]string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NowPlayingCount struct {
|
||||||
|
baseEvent
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
func (rr *RefreshResource) With(resource string, ids ...string) *RefreshResource {
|
func (rr *RefreshResource) With(resource string, ids ...string) *RefreshResource {
|
||||||
if rr.resources == nil {
|
if rr.resources == nil {
|
||||||
rr.resources = make(map[string][]string)
|
rr.resources = make(map[string][]string)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export const EVENT_SCAN_STATUS = 'scanStatus'
|
export const EVENT_SCAN_STATUS = 'scanStatus'
|
||||||
export const EVENT_SERVER_START = 'serverStart'
|
export const EVENT_SERVER_START = 'serverStart'
|
||||||
export const EVENT_REFRESH_RESOURCE = 'refreshResource'
|
export const EVENT_REFRESH_RESOURCE = 'refreshResource'
|
||||||
|
export const EVENT_NOW_PLAYING_COUNT = 'nowPlayingCount'
|
||||||
|
|
||||||
export const processEvent = (type, data) => ({
|
export const processEvent = (type, data) => ({
|
||||||
type,
|
type,
|
||||||
@@ -11,6 +12,11 @@ export const scanStatusUpdate = (data) => ({
|
|||||||
data: data,
|
data: data,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const nowPlayingCountUpdate = (data) => ({
|
||||||
|
type: EVENT_NOW_PLAYING_COUNT,
|
||||||
|
data: data,
|
||||||
|
})
|
||||||
|
|
||||||
export const serverDown = () => ({
|
export const serverDown = () => ({
|
||||||
type: EVENT_SERVER_START,
|
type: EVENT_SERVER_START,
|
||||||
data: {},
|
data: {},
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const startEventStream = async (dispatchFn) => {
|
|||||||
throttledEventHandler(dispatchFn),
|
throttledEventHandler(dispatchFn),
|
||||||
)
|
)
|
||||||
newStream.addEventListener('refreshResource', eventHandler(dispatchFn))
|
newStream.addEventListener('refreshResource', eventHandler(dispatchFn))
|
||||||
|
newStream.addEventListener('nowPlayingCount', eventHandler(dispatchFn))
|
||||||
newStream.addEventListener('keepAlive', eventHandler(dispatchFn))
|
newStream.addEventListener('keepAlive', eventHandler(dispatchFn))
|
||||||
newStream.onerror = (e) => {
|
newStream.onerror = (e) => {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
|
|||||||
@@ -540,6 +540,11 @@
|
|||||||
"status": "Scan Error",
|
"status": "Scan Error",
|
||||||
"elapsedTime": "Elapsed Time"
|
"elapsedTime": "Elapsed Time"
|
||||||
},
|
},
|
||||||
|
"nowPlaying": {
|
||||||
|
"title": "Now Playing",
|
||||||
|
"empty": "Nothing playing",
|
||||||
|
"minutesAgo": "%{smart_count} minute ago |||| %{smart_count} minutes ago"
|
||||||
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "Navidrome Hotkeys",
|
"title": "Navidrome Hotkeys",
|
||||||
"hotkeys": {
|
"hotkeys": {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Dialogs } from '../dialogs/Dialogs'
|
|||||||
import { AboutDialog } from '../dialogs'
|
import { AboutDialog } from '../dialogs'
|
||||||
import PersonalMenu from './PersonalMenu'
|
import PersonalMenu from './PersonalMenu'
|
||||||
import ActivityPanel from './ActivityPanel'
|
import ActivityPanel from './ActivityPanel'
|
||||||
|
import NowPlayingPanel from './NowPlayingPanel'
|
||||||
import UserMenu from './UserMenu'
|
import UserMenu from './UserMenu'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
|
|
||||||
@@ -119,6 +120,9 @@ const CustomUserMenu = ({ onClick, ...rest }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{config.devActivityPanel && permissions === 'admin' && (
|
||||||
|
<NowPlayingPanel />
|
||||||
|
)}
|
||||||
{config.devActivityPanel && permissions === 'admin' && <ActivityPanel />}
|
{config.devActivityPanel && permissions === 'admin' && <ActivityPanel />}
|
||||||
<UserMenu {...rest}>
|
<UserMenu {...rest}>
|
||||||
<PersonalMenu sidebarIsOpen={true} onClick={onClick} />
|
<PersonalMenu sidebarIsOpen={true} onClick={onClick} />
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Tooltip title={translate('nowPlaying.title')}>
|
||||||
|
<IconButton
|
||||||
|
className={classes.button}
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={translate('nowPlaying.title')}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
badgeContent={count}
|
||||||
|
color="primary"
|
||||||
|
overlap="rectangular"
|
||||||
|
className={classes.badge}
|
||||||
|
>
|
||||||
|
<FaRegCirclePlay size={20} />
|
||||||
|
</Badge>
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ListItem key={nowPlayingEntry.playerId} className={classes.listItem}>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<Link
|
||||||
|
to={`/album/${nowPlayingEntry.albumId}/show`}
|
||||||
|
onClick={onLinkClick}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
className={classes.avatar}
|
||||||
|
src={subsonic.getCoverArtUrl(nowPlayingEntry, 80)}
|
||||||
|
variant="square"
|
||||||
|
alt={`${nowPlayingEntry.album} cover art`}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<div className={classes.primaryText}>
|
||||||
|
{nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId ? (
|
||||||
|
<Link
|
||||||
|
to={getArtistLink(
|
||||||
|
nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId,
|
||||||
|
)}
|
||||||
|
className={classes.artistLink}
|
||||||
|
onClick={onLinkClick}
|
||||||
|
>
|
||||||
|
{nowPlayingEntry.albumArtist || nowPlayingEntry.artist}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
{nowPlayingEntry.albumArtist || nowPlayingEntry.artist}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
- {nowPlayingEntry.title}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
secondary={`${nowPlayingEntry.username}${nowPlayingEntry.playerName ? ` (${nowPlayingEntry.playerName})` : ''} • ${translate('nowPlaying.minutesAgo', { smart_count: nowPlayingEntry.minutesAgo })}`}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Popover
|
||||||
|
id="panel-nowplaying"
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
|
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
aria-labelledby="now-playing-title"
|
||||||
|
>
|
||||||
|
<Card className={classes.card}>
|
||||||
|
<CardContent className={classes.cardContent}>
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<Typography id="now-playing-title">
|
||||||
|
{translate('nowPlaying.empty')}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<List
|
||||||
|
className={classes.list}
|
||||||
|
dense
|
||||||
|
aria-label={translate('nowPlaying.title')}
|
||||||
|
>
|
||||||
|
{entries.map((nowPlayingEntry) => (
|
||||||
|
<NowPlayingItem
|
||||||
|
key={nowPlayingEntry.playerId}
|
||||||
|
nowPlayingEntry={nowPlayingEntry}
|
||||||
|
onLinkClick={onLinkClick}
|
||||||
|
getArtistLink={getArtistLink}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<NowPlayingButton count={count} onClick={handleMenuOpen} />
|
||||||
|
<NowPlayingList
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={open}
|
||||||
|
onClose={handleMenuClose}
|
||||||
|
entries={entries}
|
||||||
|
onLinkClick={handleLinkClick}
|
||||||
|
getArtistLink={getArtistLink}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
NowPlayingPanel.propTypes = {}
|
||||||
|
|
||||||
|
export default NowPlayingPanel
|
||||||
@@ -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 }) => (
|
||||||
|
<a
|
||||||
|
href={to}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault() // Prevent navigation in tests
|
||||||
|
if (onClick) onClick(e)
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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('<NowPlayingPanel />', () => {
|
||||||
|
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(
|
||||||
|
<Provider store={store}>
|
||||||
|
<NowPlayingPanel />
|
||||||
|
</Provider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<Provider store={store}>
|
||||||
|
<NowPlayingPanel />
|
||||||
|
</Provider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<Provider store={store}>
|
||||||
|
<NowPlayingPanel />
|
||||||
|
</Provider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<Provider store={store}>
|
||||||
|
<NowPlayingPanel />
|
||||||
|
</Provider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<Provider store={store}>
|
||||||
|
<NowPlayingPanel />
|
||||||
|
</Provider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
EVENT_REFRESH_RESOURCE,
|
EVENT_REFRESH_RESOURCE,
|
||||||
EVENT_SCAN_STATUS,
|
EVENT_SCAN_STATUS,
|
||||||
EVENT_SERVER_START,
|
EVENT_SERVER_START,
|
||||||
|
EVENT_NOW_PLAYING_COUNT,
|
||||||
} from '../actions'
|
} from '../actions'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ const initialState = {
|
|||||||
elapsedTime: 0,
|
elapsedTime: 0,
|
||||||
},
|
},
|
||||||
serverStart: { version: config.version },
|
serverStart: { version: config.version },
|
||||||
|
nowPlayingCount: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const activityReducer = (previousState = initialState, payload) => {
|
export const activityReducer = (previousState = initialState, payload) => {
|
||||||
@@ -40,6 +42,8 @@ export const activityReducer = (previousState = initialState, payload) => {
|
|||||||
resources: data,
|
resources: data,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
case EVENT_NOW_PLAYING_COUNT:
|
||||||
|
return { ...previousState, nowPlayingCount: data.count }
|
||||||
default:
|
default:
|
||||||
return previousState
|
return previousState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { activityReducer } from './activityReducer'
|
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'
|
import config from '../config'
|
||||||
|
|
||||||
describe('activityReducer', () => {
|
describe('activityReducer', () => {
|
||||||
@@ -12,6 +16,7 @@ describe('activityReducer', () => {
|
|||||||
elapsedTime: 0,
|
elapsedTime: 0,
|
||||||
},
|
},
|
||||||
serverStart: { version: config.version },
|
serverStart: { version: config.version },
|
||||||
|
nowPlayingCount: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
it('returns the initial state when no action is specified', () => {
|
it('returns the initial state when no action is specified', () => {
|
||||||
@@ -116,4 +121,13 @@ describe('activityReducer', () => {
|
|||||||
startTime: Date.parse('2023-01-01T00:00:00Z'),
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ const startScan = (options) => httpClient(url('startScan', null, options))
|
|||||||
|
|
||||||
const getScanStatus = () => httpClient(url('getScanStatus'))
|
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 getCoverArtUrl = (record, size, square) => {
|
||||||
const options = {
|
const options = {
|
||||||
...(record.updatedAt && { _: record.updatedAt }),
|
...(record.updatedAt && { _: record.updatedAt }),
|
||||||
@@ -110,7 +120,9 @@ export default {
|
|||||||
setRating,
|
setRating,
|
||||||
startScan,
|
startScan,
|
||||||
getScanStatus,
|
getScanStatus,
|
||||||
|
getNowPlaying,
|
||||||
getCoverArtUrl,
|
getCoverArtUrl,
|
||||||
|
getAvatarUrl,
|
||||||
streamUrl,
|
streamUrl,
|
||||||
getAlbumInfo,
|
getAlbumInfo,
|
||||||
getArtistInfo,
|
getArtistInfo,
|
||||||
|
|||||||
@@ -104,3 +104,26 @@ describe('getCoverArtUrl', () => {
|
|||||||
expect(url).not.toContain('_=')
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Vendored
+25
-1
@@ -1,8 +1,10 @@
|
|||||||
package cache
|
package cache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"runtime"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"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)
|
GetWithLoader(key K, loader func(key K) (V, time.Duration, error)) (V, error)
|
||||||
Keys() []K
|
Keys() []K
|
||||||
Values() []V
|
Values() []V
|
||||||
|
Len() int
|
||||||
|
OnExpiration(fn func(K, V)) func()
|
||||||
}
|
}
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
@@ -39,9 +43,17 @@ func NewSimpleCache[K comparable, V any](options ...Options) SimpleCache[K, V] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c := ttlcache.New[K, V](opts...)
|
c := ttlcache.New[K, V](opts...)
|
||||||
return &simpleCache[K, V]{
|
cache := &simpleCache[K, V]{
|
||||||
data: c,
|
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
|
const evictionTimeout = 1 * time.Hour
|
||||||
@@ -127,3 +139,15 @@ func (c *simpleCache[K, V]) Values() []V {
|
|||||||
})
|
})
|
||||||
return res
|
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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+14
@@ -143,5 +143,19 @@ var _ = Describe("SimpleCache", func() {
|
|||||||
Expect(cache.Get("key0")).To(Equal("value0"))
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user