diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go
index e39f0bbd3..c78e0df1e 100644
--- a/persistence/playlist_repository.go
+++ b/persistence/playlist_repository.go
@@ -51,8 +51,10 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe
r.ctx = ctx
r.db = db
r.registerModel(&model.Playlist{}, map[string]filterFunc{
- "q": playlistFilter,
- "smart": smartPlaylistFilter,
+ "id": idFilter("playlist"),
+ "q": playlistFilter,
+ "smart": smartPlaylistFilter,
+ "starred": annotationBoolFilter("starred"),
})
r.setSortMappings(map[string]string{
"owner_name": "owner_name",
diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go
index c51ff6222..00d4ff9f2 100644
--- a/persistence/playlist_repository_test.go
+++ b/persistence/playlist_repository_test.go
@@ -4,6 +4,7 @@ import (
"slices"
"github.com/Masterminds/squirrel"
+ "github.com/deluan/rest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@@ -155,6 +156,34 @@ var _ = Describe("PlaylistRepository", func() {
Expect(count).To(Equal(int64(len(starred))))
})
+ It("filters starred playlists through the registered REST filter", func() {
+ Expect(repo.SetStar(true, plsID)).To(Succeed())
+
+ res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"starred": "true"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ starred := res.(model.Playlists)
+ Expect(starred).To(ContainElement(HaveField("ID", plsID)))
+ for _, p := range starred {
+ Expect(p.Starred).To(BeTrue())
+ }
+
+ res, err = repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"starred": "false"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(res.(model.Playlists)).ToNot(ContainElement(HaveField("ID", plsID)))
+ })
+
+ It("reads a playlist by id through the REST id filter without ambiguity", func() {
+ res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"id": plsID},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(res.(model.Playlists)).To(ContainElement(HaveField("ID", plsID)))
+ })
+
It("does not leak an annotation row of another item_type sharing the playlist id", func() {
// Older builds (and the star fallthrough) can leave a media_file-typed row
// under a playlist id; the item_type-scoped join must not surface or dupe it.
diff --git a/ui/src/actions/settings.js b/ui/src/actions/settings.js
index e62ecde8f..89c8f248f 100644
--- a/ui/src/actions/settings.js
+++ b/ui/src/actions/settings.js
@@ -1,6 +1,8 @@
export const SET_NOTIFICATIONS_STATE = 'SET_NOTIFICATIONS_STATE'
export const SET_TOGGLEABLE_FIELDS = 'SET_TOGGLEABLE_FIELDS'
export const SET_OMITTED_FIELDS = 'SET_OMITTED_FIELDS'
+export const SET_SIDEBAR_PLAYLISTS_FAVOURITES =
+ 'SET_SIDEBAR_PLAYLISTS_FAVOURITES'
export const setNotificationsState = (enabled) => ({
type: SET_NOTIFICATIONS_STATE,
@@ -16,3 +18,8 @@ export const setOmittedFields = (obj) => ({
type: SET_OMITTED_FIELDS,
data: obj,
})
+
+export const setSidebarPlaylistsOnlyFavourites = (enabled) => ({
+ type: SET_SIDEBAR_PLAYLISTS_FAVOURITES,
+ data: enabled,
+})
diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json
index 74fb23ab9..c0e226453 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -210,7 +210,8 @@
"songCount": "Songs",
"comment": "Comment",
"sync": "Auto-import",
- "path": "Import from"
+ "path": "Import from",
+ "starred": "Favourite"
},
"actions": {
"selectPlaylist": "Select a playlist:",
@@ -635,6 +636,7 @@
},
"albumList": "Albums",
"playlists": "Playlists",
+ "onlyFavourites": "Only show favourites",
"sharedPlaylists": "Shared Playlists",
"about": "About"
},
diff --git a/ui/src/layout/PlaylistsSubMenu.jsx b/ui/src/layout/PlaylistsSubMenu.jsx
index b94bebf86..f332f6810 100644
--- a/ui/src/layout/PlaylistsSubMenu.jsx
+++ b/ui/src/layout/PlaylistsSubMenu.jsx
@@ -1,19 +1,24 @@
-import React, { useCallback } from 'react'
+import React, { useCallback, useMemo, useState } from 'react'
+import { useDispatch, useSelector } from 'react-redux'
import {
MenuItemLink,
useDataProvider,
useNotify,
useQueryWithStore,
+ useTranslate,
} from 'react-admin'
import { useHistory } from 'react-router-dom'
import QueueMusicIcon from '@material-ui/icons/QueueMusic'
import { Typography } from '@material-ui/core'
import QueueMusicOutlinedIcon from '@material-ui/icons/QueueMusicOutlined'
-import { BiCog } from 'react-icons/bi'
+import FavoriteIcon from '@material-ui/icons/Favorite'
+import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
+import { BiListUl } from 'react-icons/bi'
import { useDrop } from 'react-dnd'
import SubMenu from './SubMenu'
-import { canChangeTracks, OverflowTooltip } from '../common'
+import { canChangeTracks, OverflowTooltip, useRefreshOnEvents } from '../common'
import { DraggableTypes } from '../consts'
+import { setSidebarPlaylistsOnlyFavourites } from '../actions'
import config from '../config'
const PlaylistMenuItemLink = ({ pls, sidebarIsOpen }) => {
@@ -53,6 +58,37 @@ const PlaylistMenuItemLink = ({ pls, sidebarIsOpen }) => {
const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => {
const history = useHistory()
+ const dispatch = useDispatch()
+ const translate = useTranslate()
+ const onlyFavourites = useSelector(
+ (state) => state.settings.sidebarPlaylistsOnlyFavourites,
+ )
+ // Ignore a persisted preference when the feature is off, so disabling it later
+ // (with the toggle now hidden) doesn't strand the user on a filtered sidebar
+ const showFavouritesOnly = config.enableFavourites && onlyFavourites
+ const playlistData = useSelector(
+ (state) => state.admin.resources.playlist?.data,
+ )
+ // Fingerprint of local star state; changes only when a playlist is (un)starred,
+ // so a local toggle refetches the sidebar without the SSE echo the actor never gets
+ const starFingerprint = useMemo(() => {
+ const data = playlistData || {}
+ return Object.keys(data)
+ .filter((id) => data[id]?.starred)
+ .sort()
+ .join(',')
+ }, [playlistData])
+ const [refreshCount, setRefreshCount] = useState(0)
+
+ // Only the favourites-only view depends on star state changing elsewhere;
+ // when showing all playlists a star event from another client changes nothing
+ // async because useRefreshOnEvents calls .catch() on the returned value
+ const onRefresh = useCallback(async () => {
+ if (showFavouritesOnly) setRefreshCount((count) => count + 1)
+ }, [showFavouritesOnly])
+ useRefreshOnEvents({ events: ['playlist'], onRefresh })
+
+ // A changed payload signature makes useQueryWithStore refetch
const { data, loaded } = useQueryWithStore({
type: 'getList',
resource: 'playlist',
@@ -62,6 +98,11 @@ const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => {
perPage: config.maxSidebarPlaylists,
},
sort: { field: 'name' },
+ ...(showFavouritesOnly && {
+ filter: { starred: true },
+ starFingerprint,
+ refresh: refreshCount,
+ }),
},
})
@@ -98,6 +139,10 @@ const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => {
[history],
)
+ const handleToggleFavourites = useCallback(() => {
+ dispatch(setSidebarPlaylistsOnlyFavourites(!onlyFavourites))
+ }, [dispatch, onlyFavourites])
+
return (
<>
{
name={'menu.playlists'}
icon={}
dense={dense}
- actionIcon={}
+ actionIcon={}
onAction={onPlaylistConfig}
+ onSecondaryAction={
+ config.enableFavourites ? handleToggleFavourites : undefined
+ }
+ secondaryActionIcon={
+ onlyFavourites ? (
+
+ ) : (
+
+ )
+ }
+ secondaryActionTitle={translate('menu.onlyFavourites')}
+ secondaryActionActive={onlyFavourites}
>
{myPlaylists.map(renderPlaylistMenuItemLink)}
diff --git a/ui/src/layout/PlaylistsSubMenu.test.jsx b/ui/src/layout/PlaylistsSubMenu.test.jsx
new file mode 100644
index 000000000..617f60a4e
--- /dev/null
+++ b/ui/src/layout/PlaylistsSubMenu.test.jsx
@@ -0,0 +1,180 @@
+import React from 'react'
+import { render, screen, fireEvent, act } from '@testing-library/react'
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { Provider } from 'react-redux'
+import { createStore, combineReducers } from 'redux'
+import { ThemeProvider, createTheme } from '@material-ui/core/styles'
+import { settingsReducer, activityReducer } from '../reducers'
+import { processEvent, EVENT_REFRESH_RESOURCE } from '../actions'
+import PlaylistsSubMenu from './PlaylistsSubMenu'
+
+const mockUseQueryWithStore = vi.fn()
+
+vi.mock('../config', () => ({
+ // losslessFormats is read at module-load time by common/QualityInfo.jsx,
+ // pulled in transitively via the '../common' barrel file
+ default: {
+ enableFavourites: true,
+ maxSidebarPlaylists: 100,
+ losslessFormats: '',
+ },
+}))
+
+vi.mock('react-dnd', () => ({
+ useDrop: () => [{}, () => {}],
+}))
+
+vi.mock('react-router-dom', () => ({
+ useHistory: () => ({ push: vi.fn() }),
+}))
+
+vi.mock('react-admin', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useTranslate: () => (x) => x,
+ useDataProvider: () => ({ addToPlaylist: vi.fn() }),
+ useNotify: () => vi.fn(),
+ useQueryWithStore: (query) => mockUseQueryWithStore(query),
+ MenuItemLink: ({ primaryText }) =>
{primaryText}
,
+ }
+})
+
+const playlists = {
+ 'pl-1': { id: 'pl-1', name: 'Mine', ownerId: 'user-1' },
+ 'pl-2': { id: 'pl-2', name: 'Theirs', ownerId: 'user-2' },
+}
+
+const SET_PLAYLIST_DATA = 'TEST/SET_PLAYLIST_DATA'
+const adminReducer = (state = { resources: {} }, action) =>
+ action.type === SET_PLAYLIST_DATA
+ ? { resources: { playlist: { data: action.data } } }
+ : state
+
+const renderMenu = (preloadedSettings = {}, preloadedPlaylistData) => {
+ const store = createStore(
+ combineReducers({
+ settings: settingsReducer,
+ activity: activityReducer,
+ admin: adminReducer,
+ }),
+ {
+ settings: preloadedSettings,
+ activity: {},
+ admin: {
+ resources: preloadedPlaylistData
+ ? { playlist: { data: preloadedPlaylistData } }
+ : {},
+ },
+ },
+ )
+ const theme = createTheme()
+ render(
+
+
+
+
+ ,
+ )
+ return store
+}
+
+const lastQuery = () =>
+ mockUseQueryWithStore.mock.calls[
+ mockUseQueryWithStore.mock.calls.length - 1
+ ][0]
+
+describe('', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ localStorage.setItem('userId', 'user-1')
+ mockUseQueryWithStore.mockReturnValue({ data: playlists, loaded: true })
+ // SubMenu uses MUI's useMediaQuery, which needs window.matchMedia in jsdom
+ window.matchMedia = (query) => ({
+ matches: false,
+ media: query,
+ addListener: () => {},
+ removeListener: () => {},
+ })
+ // OverflowTooltip (via MenuItemLink) needs ResizeObserver, unavailable in jsdom
+ window.ResizeObserver = class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+ })
+
+ it('queries without a starred filter by default', () => {
+ renderMenu()
+ expect(lastQuery().payload.filter).toBeUndefined()
+ expect(screen.getByText('Mine')).not.toBeNull()
+ expect(screen.getByText('Theirs')).not.toBeNull()
+ })
+
+ it('adds the starred filter when favourites-only is enabled', () => {
+ renderMenu({ sidebarPlaylistsOnlyFavourites: true })
+ expect(lastQuery().payload.filter).toEqual({ starred: true })
+ })
+
+ it('toggles the setting when the heart action is clicked', () => {
+ const store = renderMenu()
+ fireEvent.click(screen.getByTitle('menu.onlyFavourites'))
+ expect(store.getState().settings.sidebarPlaylistsOnlyFavourites).toBe(true)
+ expect(lastQuery().payload.filter).toEqual({ starred: true })
+ })
+
+ it('refetches on a playlist SSE event when favourites-only is on', async () => {
+ const store = renderMenu({ sidebarPlaylistsOnlyFavourites: true })
+ const before = lastQuery().payload.refresh
+ // useRefreshOnEvents compares Date.now() timestamps; make sure it advances
+ await act(() => new Promise((resolve) => setTimeout(resolve, 5)))
+ act(() => {
+ store.dispatch(
+ processEvent(EVENT_REFRESH_RESOURCE, { playlist: ['pl-1'] }),
+ )
+ })
+ expect(lastQuery().payload.refresh).toBe(before + 1)
+ })
+
+ it('does not change the query signature on an SSE event when favourites-only is off', async () => {
+ const store = renderMenu()
+ const before = JSON.stringify(lastQuery().payload)
+ await act(() => new Promise((resolve) => setTimeout(resolve, 5)))
+ act(() => {
+ store.dispatch(
+ processEvent(EVENT_REFRESH_RESOURCE, { playlist: ['pl-1'] }),
+ )
+ })
+ // Signature unchanged → useQueryWithStore dedupes, no wasted refetch
+ expect(lastQuery().payload.refresh).toBeUndefined()
+ expect(JSON.stringify(lastQuery().payload)).toBe(before)
+ })
+
+ it('refetches when a playlist is starred locally (no SSE echo)', () => {
+ const store = renderMenu(
+ { sidebarPlaylistsOnlyFavourites: true },
+ { 'pl-1': { id: 'pl-1', name: 'Mine', ownerId: 'user-1' } },
+ )
+ const before = lastQuery().payload.starFingerprint
+ act(() => {
+ store.dispatch({
+ type: SET_PLAYLIST_DATA,
+ data: {
+ 'pl-1': {
+ id: 'pl-1',
+ name: 'Mine',
+ ownerId: 'user-1',
+ starred: true,
+ },
+ },
+ })
+ })
+ expect(lastQuery().payload.starFingerprint).not.toBe(before)
+ expect(lastQuery().payload.starFingerprint).toContain('pl-1')
+ })
+})
diff --git a/ui/src/layout/SubMenu.jsx b/ui/src/layout/SubMenu.jsx
index 418f4c651..ee1bf343e 100644
--- a/ui/src/layout/SubMenu.jsx
+++ b/ui/src/layout/SubMenu.jsx
@@ -33,6 +33,9 @@ const useStyles = makeStyles(
menuHeader: {
width: '100%',
},
+ headerText: {
+ flexGrow: 1,
+ },
headerWrapper: {
display: 'flex',
'&:hover $actionIcon': {
@@ -55,6 +58,10 @@ const SubMenu = ({
dense,
onAction,
actionIcon,
+ onSecondaryAction,
+ secondaryActionIcon,
+ secondaryActionTitle,
+ secondaryActionActive,
}) => {
const translate = useTranslate()
const classes = useStyles()
@@ -70,6 +77,11 @@ const SubMenu = ({
}
}
+ const handleSecondaryClick = (e) => {
+ e.stopPropagation()
+ onSecondaryAction(e)
+ }
+
const header = (
-
-
- {record.name || translate('ra.page.loading')}
-
-
+
+
+
+ {record.name || translate('ra.page.loading')}
+
+
+
+
{record.songCount ? (
diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx
index 8732725bc..642d90dd5 100644
--- a/ui/src/playlist/PlaylistList.jsx
+++ b/ui/src/playlist/PlaylistList.jsx
@@ -4,6 +4,7 @@ import {
DateField,
EditButton,
Filter,
+ NullableBooleanInput,
NumberField,
ReferenceInput,
SearchInput,
@@ -22,11 +23,14 @@ import {
CoverArtAvatar,
DurationField,
List,
+ LoveButton,
Writable,
isWritable,
useSelectedFields,
useResourceRefresh,
} from '../common'
+import FavoriteIcon from '@material-ui/icons/Favorite'
+import config from '../config'
import PlaylistListActions from './PlaylistListActions'
import ChangePublicStatusButton from './ChangePublicStatusButton'
@@ -53,6 +57,12 @@ const PlaylistFilter = (props) => {
)}
+ {config.enableFavourites && (
+ }
+ />
+ )}
)
}
@@ -139,6 +149,13 @@ const PlaylistListBulkActions = (props) => {
)
}
+// Datagrid reads `source`/`sortable`/`label` off this element for the column
+// header; only record/resource are forwarded so they never leak onto the button.
+export const PlaylistLove = ({ record, className }) => (
+
+)
+PlaylistLove.defaultProps = { source: 'starred', sortable: false }
+
const PlaylistList = (props) => {
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
@@ -159,6 +176,7 @@ const PlaylistList = (props) => {
sync: !isXsmall && (
),
+ starred: config.enableFavourites && ,
}),
[isDesktop, isXsmall],
)
diff --git a/ui/src/playlist/PlaylistList.test.jsx b/ui/src/playlist/PlaylistList.test.jsx
new file mode 100644
index 000000000..4fbc6d516
--- /dev/null
+++ b/ui/src/playlist/PlaylistList.test.jsx
@@ -0,0 +1,34 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import { describe, it, expect, vi } from 'vitest'
+import { PlaylistLove } from './PlaylistList'
+
+vi.mock('../config', () => ({
+ default: { enableFavourites: true },
+}))
+
+vi.mock('../common', () => ({
+ LoveButton: ({ record, resource }) => (
+
+ ),
+}))
+
+describe('', () => {
+ it('renders a LoveButton bound to the playlist resource', () => {
+ render()
+ const btn = screen.getByTestId('love')
+ expect(btn.getAttribute('data-resource')).toBe('playlist')
+ expect(btn.textContent).toBe('starred')
+ })
+
+ it('exposes datagrid header props so the column renders unsorted', () => {
+ // The Datagrid reads these off the element; the wrapper body must not
+ // forward them to the button (which would leak onto the DOM).
+ expect(PlaylistLove.defaultProps).toEqual({
+ source: 'starred',
+ sortable: false,
+ })
+ })
+})
diff --git a/ui/src/reducers/settingsReducer.js b/ui/src/reducers/settingsReducer.js
index 0e598c22d..3f8278ea4 100644
--- a/ui/src/reducers/settingsReducer.js
+++ b/ui/src/reducers/settingsReducer.js
@@ -1,6 +1,7 @@
import {
SET_NOTIFICATIONS_STATE,
SET_OMITTED_FIELDS,
+ SET_SIDEBAR_PLAYLISTS_FAVOURITES,
SET_TOGGLEABLE_FIELDS,
} from '../actions'
@@ -8,6 +9,7 @@ const initialState = {
notifications: false,
toggleableFields: {},
omittedFields: {},
+ sidebarPlaylistsOnlyFavourites: false,
}
export const settingsReducer = (previousState = initialState, payload) => {
@@ -34,6 +36,11 @@ export const settingsReducer = (previousState = initialState, payload) => {
...data,
},
}
+ case SET_SIDEBAR_PLAYLISTS_FAVOURITES:
+ return {
+ ...previousState,
+ sidebarPlaylistsOnlyFavourites: data,
+ }
default:
return previousState
}
diff --git a/ui/src/reducers/settingsReducer.test.js b/ui/src/reducers/settingsReducer.test.js
new file mode 100644
index 000000000..7ad68b291
--- /dev/null
+++ b/ui/src/reducers/settingsReducer.test.js
@@ -0,0 +1,36 @@
+import { describe, it, expect } from 'vitest'
+import { settingsReducer } from './settingsReducer'
+import {
+ SET_SIDEBAR_PLAYLISTS_FAVOURITES,
+ setSidebarPlaylistsOnlyFavourites,
+} from '../actions'
+
+describe('settingsReducer', () => {
+ it('defaults sidebarPlaylistsOnlyFavourites to false', () => {
+ const state = settingsReducer(undefined, { type: 'UNKNOWN' })
+ expect(state.sidebarPlaylistsOnlyFavourites).toBe(false)
+ })
+
+ it('enables the flag via the action creator', () => {
+ const state = settingsReducer(
+ undefined,
+ setSidebarPlaylistsOnlyFavourites(true),
+ )
+ expect(state.sidebarPlaylistsOnlyFavourites).toBe(true)
+ })
+
+ it('disables the flag and preserves other settings', () => {
+ const initial = settingsReducer(undefined, { type: 'UNKNOWN' })
+ const on = settingsReducer(initial, {
+ type: SET_SIDEBAR_PLAYLISTS_FAVOURITES,
+ data: true,
+ })
+ const off = settingsReducer(on, {
+ type: SET_SIDEBAR_PLAYLISTS_FAVOURITES,
+ data: false,
+ })
+ expect(off.sidebarPlaylistsOnlyFavourites).toBe(false)
+ expect(off.notifications).toEqual(initial.notifications)
+ expect(off.toggleableFields).toEqual(initial.toggleableFields)
+ })
+})