From deaa5e6c028abd70acb2427c50c478e79eab61dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 14:45:47 -0400 Subject: [PATCH 01/19] feat(ui): playlist favourites (heart, list filter, sidebar favourites-only toggle) (#5805) * feat(playlists): register starred REST filter on playlist repository * feat(ui): add persisted sidebarPlaylistsOnlyFavourites setting * feat(ui): playlist favourites heart column and list filter * feat(ui): favourite heart on playlist details header * feat(ui): sidebar favourites-only playlist toggle with live refresh * fix(playlists): qualify id in REST filter to avoid ambiguous column The annotation join in selectPlaylist made a bare id filter ambiguous, so GET /api/playlist?id=X (react-admin getMany, used by the sidebar refetch and useResourceRefresh) failed with 'ambiguous column name: id'. Register idFilter("playlist") like the album/artist/mediafile repos. * fix(ui): refresh favourites sidebar on local star toggle The SSE broker skips the client that originated a star, so the acting client never got the refreshResource echo and its favourites-only sidebar went stale. Key the sidebar query on a fingerprint of locally-known starred playlists so a star/unstar on this client refetches; SSE still covers other clients. * style(ui): prettier-format PlaylistsSubMenu test * feat(ui): refine playlist favourites layout - Move the favourite heart column to just before the edit button - Space the Playlists sidebar header text from its action icons - Use a list icon instead of a cog for the playlist-management action * feat(ui): make the playlist Favourite column toggleable Move the heart into the toggleable fields map so users can show/hide it from the column selector like the other optional columns, keeping it last so it stays just before the edit button. * refactor(ui): memoize sidebar star fingerprint and gate it on favourites-only - Derive starFingerprint via useMemo on the playlist data reference instead of recomputing sort/join inside useSelector on every app-wide dispatch. - Only include the fingerprint in the query payload when favourites-only is on, so a star toggle no longer refetches the sidebar when it shows all playlists. * fix(ui): don't refetch favourites sidebar on SSE events when showing all When favourites-only is off the sidebar shows every playlist, so a star event from another client changes nothing visible. Gate the SSE-driven refresh counter (and its payload key) on onlyFavourites so the sidebar no longer redraws on unrelated playlist star events. * fix(ui): address automated review feedback on playlist favourites - Ignore a persisted favourites-only preference when EnableFavourites is off, so disabling the feature later can't strand a filtered sidebar (Codex P2). - Make PlaylistLove's datagrid header props explicit (source/sortable via defaultProps, className forwarded) instead of relying on prop pass-through. - Add aria-label to the SubMenu secondary action button. - Cover the PlaylistLove list column with tests (Codex P1). --- persistence/playlist_repository.go | 6 +- persistence/playlist_repository_test.go | 29 ++++ ui/src/actions/settings.js | 7 + ui/src/i18n/en.json | 4 +- ui/src/layout/PlaylistsSubMenu.jsx | 65 ++++++++- ui/src/layout/PlaylistsSubMenu.test.jsx | 180 ++++++++++++++++++++++++ ui/src/layout/SubMenu.jsx | 31 +++- ui/src/playlist/PlaylistDetails.jsx | 36 +++-- ui/src/playlist/PlaylistList.jsx | 18 +++ ui/src/playlist/PlaylistList.test.jsx | 34 +++++ ui/src/reducers/settingsReducer.js | 7 + ui/src/reducers/settingsReducer.test.js | 36 +++++ 12 files changed, 437 insertions(+), 16 deletions(-) create mode 100644 ui/src/layout/PlaylistsSubMenu.test.jsx create mode 100644 ui/src/playlist/PlaylistList.test.jsx create mode 100644 ui/src/reducers/settingsReducer.test.js 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 = (
{isOpen ? : icon} - + {translate(name)} + {onSecondaryAction && sidebarIsOpen && ( + + {secondaryActionIcon} + + )} {onAction && sidebarIsOpen && ( {
- - - {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) + }) +}) From f61b4eee210d692f50d37d3bad5b3714e28a094d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 16:40:10 -0400 Subject: [PATCH 02/19] fix(deezer): pick most-popular artist among same-name matches (#5808) * fix(deezer): pick most-popular artist among same-name matches The Deezer agent searched with order=RANKING and always took the top-ranked result (artists[0]) as long as its name matched. Deezer's RANKING order isn't reliable for homonyms, so for names shared by several artists (e.g. "Queen") it locked onto a low-popularity artist whose Top Tracks are empty, leaving getTopSongs empty. Among the exact-name matches, select the one with the highest fan count instead. This resolves "Queen" to the real band (Deezer ID 412) and preserves the existing ErrNotFound behavior when nothing matches the name exactly. Fixes #5802 * fix(deezer): improve artist disambiguation by ranking exact-case names Signed-off-by: Deluan --------- Signed-off-by: Deluan --- adapters/deezer/deezer.go | 31 +++++++++++++++--- adapters/deezer/deezer_test.go | 60 ++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/adapters/deezer/deezer.go b/adapters/deezer/deezer.go index ed3071766..d8e832cf1 100644 --- a/adapters/deezer/deezer.go +++ b/adapters/deezer/deezer.go @@ -1,10 +1,12 @@ package deezer import ( + "cmp" "context" "errors" "fmt" "net/http" + "slices" "strings" "github.com/navidrome/navidrome/conf" @@ -95,13 +97,32 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e } } - // If the first one has the same name, that's the one - if !strings.EqualFold(artists[0].Name, name) { - log.Trace(ctx, "Top artist do not match", "searched_name", name, "found_name", artists[0].Name) + // Deezer's RANKING order isn't reliable for homonyms: rank name matches + // ahead of non-matches, prefer an exact-case match, then the most fans. + rank := func(a Artist) int { + switch { + case a.Name == name: + return 2 + case strings.EqualFold(a.Name, name): + return 1 + default: + return 0 + } + } + slices.SortFunc(artists, func(a, b Artist) int { + return cmp.Or( + cmp.Compare(rank(b), rank(a)), + cmp.Compare(b.NbFan, a.NbFan), + cmp.Compare(a.ID, b.ID), + ) + }) + best := artists[0] + if !strings.EqualFold(best.Name, name) { + log.Trace(ctx, "No artist matched the searched name", "searched_name", name, "found_name", artists[0].Name) return nil, agents.ErrNotFound } - log.Trace(ctx, "Found artist", "name", artists[0].Name, "id", artists[0].ID, "link", artists[0].Link) - return &artists[0], err + log.Trace(ctx, "Found artist", "name", best.Name, "id", best.ID, "link", best.Link, "nb_fan", best.NbFan) + return new(best), nil } func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) { diff --git a/adapters/deezer/deezer_test.go b/adapters/deezer/deezer_test.go index 4dd251585..f478af115 100644 --- a/adapters/deezer/deezer_test.go +++ b/adapters/deezer/deezer_test.go @@ -34,6 +34,66 @@ var _ = Describe("deezerAgent", func() { }) }) + Describe("searchArtist", func() { + var agent *deezerAgent + var httpClient *fakeHttpClient + + BeforeEach(func() { + httpClient = &fakeHttpClient{} + agent = &deezerAgent{ + dataStore: &tests.MockDataStore{}, + client: newClient(httpClient), + } + }) + + It("picks the exact-name match with the most fans when several share the name", func() { + // Deezer RANKING order returns a low-popularity homonym first (see issue #5802) + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[ + {"id":61045802,"name":"Queen","nb_fan":75}, + {"id":141954732,"name":"Queen","nb_fan":397}, + {"id":135041032,"name":"Queen(Ares)","nb_fan":133}, + {"id":183179807,"name":"Queen","nb_fan":53}, + {"id":412,"name":"Queen","nb_fan":12744378} + ],"total":5}`)), + }) + + artist, err := agent.searchArtist(ctx, "Queen") + + Expect(err).ToNot(HaveOccurred()) + Expect(artist.ID).To(Equal(412)) + }) + + It("matches the name case-insensitively", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[ + {"id":1,"name":"QUEEN","nb_fan":10}, + {"id":2,"name":"queen","nb_fan":20} + ],"total":2}`)), + }) + + artist, err := agent.searchArtist(ctx, "Queen") + + Expect(err).ToNot(HaveOccurred()) + Expect(artist.ID).To(Equal(2)) + }) + + It("returns ErrNotFound when no result matches the name exactly", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[ + {"id":1,"name":"Queens of the Stone Age","nb_fan":100} + ],"total":1}`)), + }) + + _, err := agent.searchArtist(ctx, "Queen") + + Expect(err).To(MatchError(agents.ErrNotFound)) + }) + }) + Describe("GetArtistBiography - Language Fallback", func() { var agent *deezerAgent var httpClient *langAwareHttpClient From e4a423db11bae287a2d41980e36536d8f1c00a9f Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 18 Jul 2026 16:49:31 -0400 Subject: [PATCH 03/19] feat(jellyfin): emit refreshResource events on favorite/rating changes Like Subsonic's setStar/setRating, the Jellyfin favorite and rating endpoints now broadcast a refreshResource event, so the web UI updates immediately when a Jellyfin client changes an annotation. Also fixes model.GetEntityByID to propagate unexpected repository errors instead of reporting them as not-found, preserving the 500-vs-404 distinction for all its callers. --- cmd/wire_gen.go | 2 +- model/get_entity.go | 35 ++++++------- model/get_entity_test.go | 40 +++++++++++++++ server/jellyfin/annotations.go | 72 ++++++++++++--------------- server/jellyfin/annotations_test.go | 64 +++++++++++++++++++++++- server/jellyfin/api.go | 6 ++- server/jellyfin/api_test.go | 10 ++-- server/jellyfin/e2e/e2e_suite_test.go | 1 + server/jellyfin/routing_test.go | 2 +- server/jellyfin/socket_test.go | 2 +- 10 files changed, 163 insertions(+), 71 deletions(-) create mode 100644 model/get_entity_test.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 463ac7a44..c4a797ef4 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -138,7 +138,7 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) - router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics) + router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker) return router } diff --git a/model/get_entity.go b/model/get_entity.go index 60972b2e9..3e1a78d1d 100644 --- a/model/get_entity.go +++ b/model/get_entity.go @@ -2,29 +2,26 @@ package model import ( "context" + "errors" ) // TODO: Should the type be encoded in the ID? func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) { - ar, err := ds.Artist(ctx).Get(id) - if err == nil { - return ar, nil + getters := []func() (any, error){ + func() (any, error) { return ds.Artist(ctx).Get(id) }, + func() (any, error) { return ds.Album(ctx).Get(id) }, + func() (any, error) { return ds.Playlist(ctx).Get(id) }, + func() (any, error) { return ds.MediaFile(ctx).Get(id) }, + func() (any, error) { return ds.Radio(ctx).Get(id) }, } - al, err := ds.Album(ctx).Get(id) - if err == nil { - return al, nil + for _, get := range getters { + entity, err := get() + if err == nil { + return entity, nil + } + if !errors.Is(err, ErrNotFound) { + return nil, err + } } - pls, err := ds.Playlist(ctx).Get(id) - if err == nil { - return pls, nil - } - mf, err := ds.MediaFile(ctx).Get(id) - if err == nil { - return mf, nil - } - r, err := ds.Radio(ctx).Get(id) - if err == nil { - return r, nil - } - return nil, err + return nil, ErrNotFound } diff --git a/model/get_entity_test.go b/model/get_entity_test.go new file mode 100644 index 000000000..f8a4c9e8e --- /dev/null +++ b/model/get_entity_test.go @@ -0,0 +1,40 @@ +package model_test + +import ( + "context" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GetEntityByID", func() { + var ds *tests.MockDataStore + var ctx context.Context + + BeforeEach(func() { + ds = &tests.MockDataStore{} + ctx = GinkgoT().Context() + }) + + It("returns the entity matching the id", func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + entity, err := model.GetEntityByID(ctx, ds, "a1") + Expect(err).ToNot(HaveOccurred()) + Expect(entity).To(BeAssignableToTypeOf(&model.Album{})) + Expect(entity.(*model.Album).ID).To(Equal("a1")) + }) + + It("returns ErrNotFound when no entity matches", func() { + _, err := model.GetEntityByID(ctx, ds, "missing") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("propagates unexpected repository errors instead of reporting not-found", func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetError(true) + _, err := model.GetEntityByID(ctx, ds, "a1") + Expect(err).To(HaveOccurred()) + Expect(err).ToNot(MatchError(model.ErrNotFound)) + }) +}) diff --git a/server/jellyfin/annotations.go b/server/jellyfin/annotations.go index 8f900ee87..f2c2f546e 100644 --- a/server/jellyfin/annotations.go +++ b/server/jellyfin/annotations.go @@ -8,53 +8,41 @@ import ( "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/utils/req" ) -// resolveAnnotated finds which annotated repo owns id. Albums and songs 404 when the user can't -// access their library; artists span libraries (library_artist), so have no single LibraryID to -// gate on and rely on list-time scoping. PlaylistRepository.Get enforces playlist visibility. -// When ok is false the response has already been written, so callers must return without writing -// the annotation. -func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, ok bool) { +// resolveAnnotated finds which annotated repo owns id, returning the resource name used in +// refreshResource events. Albums and songs 404 when the user can't access their library; artists +// span libraries (library_artist), so have no single LibraryID to gate on and rely on list-time +// scoping. PlaylistRepository.Get enforces playlist visibility. When repo is nil the response has +// already been written, so callers must return without writing the annotation. +func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, resource string) { ctx := r.Context() + entity, err := model.GetEntityByID(ctx, api.ds, id) + if err != nil && !errors.Is(err, model.ErrNotFound) { + api.internalError(w, r, err) + return nil, "" + } u, _ := request.UserFrom(ctx) - if al, err := api.ds.Album(ctx).Get(id); err == nil { - if !u.HasLibraryAccess(al.LibraryID) { - http.Error(w, "Not Found", http.StatusNotFound) - return nil, false + switch e := entity.(type) { + case *model.Album: + if u.HasLibraryAccess(e.LibraryID) { + return api.ds.Album(ctx), "album" } - return api.ds.Album(ctx), true - } else if !errors.Is(err, model.ErrNotFound) { - api.internalError(w, r, err) - return nil, false - } - if _, err := api.ds.Artist(ctx).Get(id); err == nil { - return api.ds.Artist(ctx), true - } else if !errors.Is(err, model.ErrNotFound) { - api.internalError(w, r, err) - return nil, false - } - if mf, err := api.ds.MediaFile(ctx).Get(id); err == nil { - if !u.HasLibraryAccess(mf.LibraryID) { - http.Error(w, "Not Found", http.StatusNotFound) - return nil, false + case *model.Artist: + return api.ds.Artist(ctx), "artist" + case *model.MediaFile: + if u.HasLibraryAccess(e.LibraryID) { + return api.ds.MediaFile(ctx), "song" } - return api.ds.MediaFile(ctx), true - } else if !errors.Is(err, model.ErrNotFound) { - api.internalError(w, r, err) - return nil, false - } - playlistRepo := api.ds.Playlist(ctx) - if _, err := playlistRepo.Get(id); err == nil { - return playlistRepo, true - } else if !errors.Is(err, model.ErrNotFound) { - api.internalError(w, r, err) - return nil, false + case *model.Playlist: + return api.ds.Playlist(ctx), "playlist" } + // Unknown ids, inaccessible-library items and non-annotatable entities (radios) all read as absent. http.Error(w, "Not Found", http.StatusNotFound) - return nil, false + return nil, "" } // getUserItemData returns the caller's play/favorite/rating state for a single item. Jellify @@ -77,14 +65,15 @@ func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) { func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) { id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) - repo, ok := api.resolveAnnotated(w, r, id) - if !ok { + repo, resource := api.resolveAnnotated(w, r, id) + if repo == nil { return } if err := repo.SetStar(starred, id); err != nil { api.internalError(w, r, err) return } + api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id)) encodedID := dto.EncodeID(id) api.ok(w, r, &dto.UserItemDataDto{IsFavorite: starred, Key: encodedID, ItemId: encodedID}) } @@ -96,14 +85,15 @@ func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) { func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) { id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) - repo, ok := api.resolveAnnotated(w, r, id) - if !ok { + repo, resource := api.resolveAnnotated(w, r, id) + if repo == nil { return } if err := repo.SetRating(rating, id); err != nil { api.internalError(w, r, err) return } + api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id)) encodedID := dto.EncodeID(id) d := &dto.UserItemDataDto{Key: encodedID, ItemId: encodedID} if rating > 0 { diff --git a/server/jellyfin/annotations_test.go b/server/jellyfin/annotations_test.go index 4a7efabd7..9e811a379 100644 --- a/server/jellyfin/annotations_test.go +++ b/server/jellyfin/annotations_test.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -17,6 +18,7 @@ import ( var _ = Describe("Annotations", func() { var api *Router var ds *tests.MockDataStore + var broker *fakeEventBroker // alice has access to library 1 only. ctxUser := func() context.Context { return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) @@ -24,7 +26,8 @@ var _ = Describe("Annotations", func() { BeforeEach(func() { ds = &tests.MockDataStore{} - api = &Router{ds: ds} + broker = &fakeEventBroker{} + api = &Router{ds: ds, broker: broker} }) Describe("markFavorite / unmarkFavorite", func() { @@ -134,6 +137,39 @@ var _ = Describe("Annotations", func() { invoke(api.markFavorite, w, r) Expect(w.Code).To(Equal(http.StatusInternalServerError)) }) + + It("emits a refreshResource event when starring a song", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.markFavorite, w, r) + Expect(broker.Events).To(HaveLen(1)) + Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["s1"]}`)) + }) + + It("emits a refreshResource event when starring an album", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.markFavorite, w, r) + Expect(broker.Events).To(HaveLen(1)) + Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"album":["a1"]}`)) + }) + + It("does not emit an event when the item is not accessible", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(broker.Events).To(BeEmpty()) + }) }) Describe("setRating / removeRating", func() { @@ -253,5 +289,31 @@ var _ = Describe("Annotations", func() { Expect(w.Code).To(Equal(http.StatusOK)) Expect(mfRepo.Data["s1"].Rating).To(Equal(0)) }) + + It("emits a refreshResource event when rating a song", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", "s1") + invoke(api.setRating, w, r) + Expect(broker.Events).To(HaveLen(1)) + Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["s1"]}`)) + }) }) }) + +type fakeEventBroker struct { + http.Handler + Events []events.Event +} + +func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) { + f.Events = append(f.Events, event) +} + +func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) { + f.Events = append(f.Events, event) +} + +var _ events.Broker = (*fakeEventBroker)(nil) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 5e7cad0b8..0f79011c7 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -22,6 +22,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/utils/cache" ) @@ -38,6 +39,7 @@ type Router struct { provider external.Provider sonic sonic.Engine lyrics lyrics.Lyrics + broker events.Broker lyricsCache cache.SimpleCache[string, model.LyricList] similarFlight singleflight.Group serverIDMu sync.Mutex @@ -47,11 +49,11 @@ type Router struct { func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, transcodeDecider stream.TranscodeDecider, players core.Players, scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider, - sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics) *Router { + sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics, broker events.Broker) *Router { r := &Router{ ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider, players: players, scrobbler: scrobbler, playlists: playlists, provider: provider, - sonic: sonicSvc, lyrics: lyricsSvc, + sonic: sonicSvc, lyrics: lyricsSvc, broker: broker, lyricsCache: cache.NewSimpleCache[string, model.LyricList](cache.Options{ SizeLimit: 1000, DefaultTTL: 5 * time.Minute, diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go index 62af74794..e8e9cbd4b 100644 --- a/server/jellyfin/api_test.go +++ b/server/jellyfin/api_test.go @@ -18,7 +18,7 @@ import ( var _ = Describe("Router", func() { It("serves the public handshake through the mounted handler", func() { ds := &tests.MockDataStore{} - api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/System/Info/Public", nil) api.ServeHTTP(w, r) @@ -26,7 +26,7 @@ var _ = Describe("Router", func() { }) It("returns 404 JSON for unknown routes", func() { - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Nonexistent/Route", nil) api.ServeHTTP(w, r) @@ -36,7 +36,7 @@ var _ = Describe("Router", func() { }) It("returns 404 JSON for a known path with an unsupported method", func() { - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("PATCH", "/System/Info/Public", nil) api.ServeHTTP(w, r) @@ -53,7 +53,7 @@ var _ = Describe("Router", func() { Expect(err).ToNot(HaveOccurred()) fp := &fakePlayers{} - api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil) + api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil, nil) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Users/Me", nil) @@ -70,7 +70,7 @@ var _ = Describe("Router", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.AuthRequestLimit = 2 conf.Server.AuthWindowLength = time.Minute - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) login := func() int { w := httptest.NewRecorder() diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index e0f7b5bb5..445566387 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -329,6 +329,7 @@ func setupTestDB() { providerFake, sonicSvc, lyrics.NewLyrics(ds, nil), + events.NoopBroker(), ) } diff --git a/server/jellyfin/routing_test.go b/server/jellyfin/routing_test.go index b0bc92caf..62b52cfff 100644 --- a/server/jellyfin/routing_test.go +++ b/server/jellyfin/routing_test.go @@ -19,7 +19,7 @@ var _ = Describe("Case-insensitive routing", func() { var api *Router BeforeEach(func() { - api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) It("serves a fully lowercase path directly", func() { diff --git a/server/jellyfin/socket_test.go b/server/jellyfin/socket_test.go index 7d8874476..8c509fe91 100644 --- a/server/jellyfin/socket_test.go +++ b/server/jellyfin/socket_test.go @@ -94,7 +94,7 @@ var _ = Describe("handleSocket", func() { Expect(err).ToNot(HaveOccurred()) token = t - api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil) + api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) It("upgrades when authenticated via the api_key query parameter", func() { From 9c7cf7d734e0a34fd47bdbd66c3e87983f5b383e Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:46:11 +0000 Subject: [PATCH 04/19] feat(jellyfin): expose genreitems for song/album (#5809) * feat(jellyfin): expose genreitems for song/album * use encode id instead --- server/jellyfin/dto/dto.go | 1 + server/jellyfin/dto/mappers.go | 2 ++ server/jellyfin/dto/mappers_test.go | 7 ++++++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 9847a239c..2a817739b 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -70,6 +70,7 @@ type BaseItemDto struct { Artists []string `json:"Artists,omitempty"` ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` Genres []string `json:"Genres,omitempty"` + GenreItems []NameGuidPair `json:"GenreItems,omitempty"` ChildCount *int `json:"ChildCount,omitempty"` SongCount *int `json:"SongCount,omitempty"` AlbumCount *int `json:"AlbumCount,omitempty"` diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index c65b8933d..2894edd6e 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -175,6 +175,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { if len(mf.Genres) > 0 { for _, g := range mf.Genres { item.Genres = append(item.Genres, g.Name) + item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name}) } } else if mf.Genre != "" { item.Genres = []string{mf.Genre} @@ -216,6 +217,7 @@ func AlbumToBaseItem(al model.Album) BaseItemDto { if len(al.Genres) > 0 { for _, g := range al.Genres { item.Genres = append(item.Genres, g.Name) + item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name}) } } return item diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index e72efd617..db0c56aa3 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -15,6 +15,7 @@ var _ = Describe("mappers", func() { ID: "song-1", Title: "Song", Album: "Alb", AlbumID: "alb-1", Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1, Year: 1999, Duration: 60, Size: 2_500_000, + Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}}, } mf.PlayCount = 2 mf.Starred = true @@ -35,6 +36,8 @@ var _ = Describe("mappers", func() { Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1"))) Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.AlbumPrimaryImageTag)) Expect(item.ImageBlurHashes["Primary"][item.AlbumPrimaryImageTag]).To(HaveLen(6)) + Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) + Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) Describe("Fields gating (matches real Jellyfin)", func() { @@ -198,7 +201,7 @@ var _ = Describe("mappers", func() { }) It("maps an album to a MusicAlbum folder item", func() { - al := model.Album{ID: "alb-1", Name: "Alb", AlbumArtist: "AA", AlbumArtistID: "art-1", MaxYear: 1999, SongCount: 10} + al := model.Album{ID: "alb-1", Name: "Alb", AlbumArtist: "AA", AlbumArtistID: "art-1", MaxYear: 1999, SongCount: 10, Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}}} item := AlbumToBaseItem(al) Expect(item.Type).To(Equal("MusicAlbum")) Expect(item.IsFolder).To(BeTrue()) @@ -211,6 +214,8 @@ var _ = Describe("mappers", func() { Expect(*item.ChildCount).To(Equal(10)) Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.ImageTags["Primary"])) Expect(item.ImageBlurHashes["Primary"][item.ImageTags["Primary"]]).To(HaveLen(6)) + Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) + Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) It("maps an artist to a MusicArtist folder item", func() { From 09ac342f5ae58ca69d60ca2d0cdf2302cf5bf76e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 18:16:52 -0400 Subject: [PATCH 05/19] fix(jellyfin): split Artists/ArtistItems per track artist (#5810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(jellyfin): split Artists/ArtistItems per track artist The Jellyfin API returned a single ArtistItems entry built from the flattened display artist, so a multi-artist track (e.g. "De La Soul feat. Redman") lost its individual artists and dropped every artist id but the first — while the same track's OpenSubsonic response correctly split them. Real Jellyfin splits Artists/ArtistItems one entry per track artist. Build both from Participants[RoleArtist], which is already loaded on the search and browse paths, falling back to the flattened display fields when absent. AlbumArtists stays a single credit, matching real Jellyfin. * fix(jellyfin): omit empty Artists in the fallback path When a track has no participants and no display artist, the fallback set Artists to a single-element slice holding an empty string. Only populate it when the display artist is non-empty, so untagged tracks omit the field instead of sending [""]. --- server/jellyfin/dto/mappers.go | 21 +++++++++++++++------ server/jellyfin/dto/mappers_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 2894edd6e..dc9bc5fbd 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -6,6 +6,7 @@ import ( "time" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" ) // Jellyfin wire times are ticks: 100ns units, i.e. 10,000 per millisecond. @@ -139,7 +140,6 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { Album: mf.Album, AlbumId: EncodeID(mf.AlbumID), AlbumArtist: mf.AlbumArtist, - Artists: []string{mf.Artist}, RunTimeTicks: TicksFromSeconds(mf.Duration), DateCreated: jellyfinDate(&mf.CreatedAt), Container: mf.Suffix, @@ -153,11 +153,20 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { if fields.Has("SortName") { item.SortName = cmp.Or(mf.SortTitle, mf.OrderTitle, mf.Title) } - // Finamp's Now Playing screen reads ArtistItems for the displayed artist (falling back to "Unknown - // Artist" if absent), even though Artists carries the same name. ArtistItems is the track artist; - // AlbumArtists the album artist. - if mf.ArtistID != "" { - item.ArtistItems = []NameGuidPair{{Name: mf.Artist, Id: EncodeID(mf.ArtistID)}} + // Real Jellyfin splits Artists/ArtistItems per track artist (AlbumArtists stays a single credit). + // Participants holds the per-artist list; fall back to the flattened display fields when absent. + if artists := mf.Participants[model.RoleArtist]; len(artists) > 0 { + item.Artists = slice.Map(artists, func(p model.Participant) string { return p.Name }) + item.ArtistItems = slice.Map(artists, func(p model.Participant) NameGuidPair { + return NameGuidPair{Name: p.Name, Id: EncodeID(p.ID)} + }) + } else { + if mf.Artist != "" { + item.Artists = []string{mf.Artist} + } + if mf.ArtistID != "" { + item.ArtistItems = []NameGuidPair{{Name: mf.Artist, Id: EncodeID(mf.ArtistID)}} + } } if mf.AlbumArtistID != "" { item.AlbumArtists = []NameGuidPair{{Name: mf.AlbumArtist, Id: EncodeID(mf.AlbumArtistID)}} diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index db0c56aa3..6bfc9d95a 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -99,6 +99,32 @@ var _ = Describe("mappers", func() { Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song", Artist: "X"}, nil).ArtistItems).To(BeNil()) }) + It("omits Artists when the track has no artist name or participants", func() { + Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).Artists).To(BeNil()) + }) + + It("splits Artists and ArtistItems per track artist from Participants", func() { + mf := model.MediaFile{ + ID: "s1", Title: "Oooh", + Artist: "De La Soul feat. Redman", ArtistID: "ar-delasoul", + AlbumArtist: "De La Soul", AlbumArtistID: "ar-delasoul", + } + mf.Participants = model.Participants{ + model.RoleArtist: model.ParticipantList{ + {Artist: model.Artist{ID: "ar-delasoul", Name: "De La Soul"}}, + {Artist: model.Artist{ID: "ar-redman", Name: "Redman"}}, + }, + } + item := SongToBaseItem(mf, nil) + Expect(item.Artists).To(Equal([]string{"De La Soul", "Redman"})) + Expect(item.ArtistItems).To(Equal([]NameGuidPair{ + {Name: "De La Soul", Id: EncodeID("ar-delasoul")}, + {Name: "Redman", Id: EncodeID("ar-redman")}, + })) + // AlbumArtists stays single, matching real Jellyfin. + Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID("ar-delasoul")}})) + }) + It("builds a MediaSourceInfo from a media file", func() { mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} src := MediaSourceFromMediaFile(mf) From 1e82f515c462bc08c1f0ffe058e286a20560626c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 18:59:08 -0400 Subject: [PATCH 06/19] fix(jellyfin): honor repeated Fields query params (#5811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Fields param was read with StringOr, which keeps only one value, so a request sending it as repeated params (Fields=Genres&Fields=MediaSources) — as Finamp and Feishin do — lost all but the first. Field-gated data like MediaSources was then omitted for Audio items even though the client asked for it; the comma-separated form happened to work because ParseFields splits on commas. Real Jellyfin accepts both forms. ParseFields is now variadic and a parseFields helper reads every repeated value via req.Values.Strings, applied to the item list, single-item, and playlist endpoints. --- server/jellyfin/dto/fields.go | 13 ++++++++----- server/jellyfin/dto/fields_test.go | 26 ++++++++++++++++++++++++++ server/jellyfin/e2e/browsing_test.go | 10 ++++++++++ server/jellyfin/items.go | 11 +++++++++-- server/jellyfin/playlists.go | 2 +- 5 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 server/jellyfin/dto/fields_test.go diff --git a/server/jellyfin/dto/fields.go b/server/jellyfin/dto/fields.go index faa31ec0d..fb49e43c7 100644 --- a/server/jellyfin/dto/fields.go +++ b/server/jellyfin/dto/fields.go @@ -7,12 +7,15 @@ import "strings" // omits those unless the client asks for them. type Fields map[string]struct{} -// ParseFields splits the comma-separated Fields param into a lowercased set. -func ParseFields(csv string) Fields { +// ParseFields builds a lowercased set from the Fields param. It accepts each value comma-separated +// (Fields=a,b) and across repeated params (Fields=a&Fields=b), both of which real Jellyfin honors. +func ParseFields(values ...string) Fields { f := Fields{} - for name := range strings.SplitSeq(csv, ",") { - if name = strings.TrimSpace(strings.ToLower(name)); name != "" { - f[name] = struct{}{} + for _, csv := range values { + for name := range strings.SplitSeq(csv, ",") { + if name = strings.TrimSpace(strings.ToLower(name)); name != "" { + f[name] = struct{}{} + } } } return f diff --git a/server/jellyfin/dto/fields_test.go b/server/jellyfin/dto/fields_test.go new file mode 100644 index 000000000..59d06734d --- /dev/null +++ b/server/jellyfin/dto/fields_test.go @@ -0,0 +1,26 @@ +package dto + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ParseFields", func() { + It("parses a single comma-separated value", func() { + f := ParseFields("Genres,MediaSources") + Expect(f.Has("Genres")).To(BeTrue()) + Expect(f.Has("MediaSources")).To(BeTrue()) + }) + + It("parses fields spread across repeated params", func() { + f := ParseFields("Genres", "MediaSources", "SortName") + Expect(f.Has("Genres")).To(BeTrue()) + Expect(f.Has("MediaSources")).To(BeTrue()) + Expect(f.Has("SortName")).To(BeTrue()) + }) + + It("returns an empty set for no values", func() { + Expect(ParseFields()).To(BeEmpty()) + Expect(ParseFields("")).To(BeEmpty()) + }) +}) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 18565c635..382ec9406 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -62,6 +62,16 @@ var _ = Describe("Browsing", func() { } }) + // Clients (Finamp, Feishin) send Fields as repeated params rather than one comma-separated + // value; real Jellyfin accepts both, so a later Fields=MediaSources must still take effect. + It("honors MediaSources when Fields is sent as repeated params", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Fields=Genres&Fields=MediaSources")) + Expect(q.Items).ToNot(BeEmpty()) + for _, it := range q.Items { + Expect(it.MediaSources).To(HaveLen(1)) + } + }) + It("lists all album artists", func() { q := queryResult(get("/Items?IncludeItemTypes=MusicArtist&Recursive=true")) Expect(q.TotalRecordCount).To(Equal(4)) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index b9372c397..6ed24b056 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -224,13 +224,20 @@ type itemsQuery struct { albumIds []string } +// parseFields reads the Fields param, accepting both repeated params (Fields=a&Fields=b) and a +// comma-separated value (Fields=a,b), matching real Jellyfin. StringOr would keep only one value. +func parseFields(p *req.Values) dto.Fields { + values, _ := p.Strings("fields") + return dto.ParseFields(values...) +} + // parseItemsQuery also resolves the entity types (inferring them from the parent when // IncludeItemTypes is absent) and the library scope. Query keys are read lowercase because // normalizeQueryKeys folded them (Jellyfin binds case-insensitively). func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQuery { p := req.Params(r) q := itemsQuery{ - fields: dto.ParseFields(p.StringOr("fields", "")), + fields: parseFields(p), ids: decodedQueryIDs(r, "ids"), rawTypes: p.StringOr("includeitemtypes", ""), search: searchTerm(p), @@ -730,7 +737,7 @@ func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fiel func (api *Router) getItem(w http.ResponseWriter, r *http.Request) { id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) - fields := dto.ParseFields(req.Params(r).StringOr("fields", "")) + fields := parseFields(req.Params(r)) if item, ok := api.resolveItemByID(r.Context(), id, fields); ok { api.ok(w, r, item) return diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index 084ff2975..4d3959a79 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -191,7 +191,7 @@ func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) { return } p := req.Params(r) - fields := dto.ParseFields(p.StringOr("fields", "")) + fields := parseFields(p) res, err := api.playlistTrackPage(repo, fields, p.IntOr("startindex", 0), p.IntOr("limit", 0)) if err != nil { api.internalError(w, r, err) From 3158451b8d56cabf63e6b43b818bab99534d0396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 19:30:04 -0400 Subject: [PATCH 07/19] refactor(server): drop redundant error return from req.Strings parsing (#5812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(req): drop redundant error return from Strings The error from Strings carried no information beyond emptiness — it fired exactly when the param was absent — and nearly every caller discarded it with a blank identifier. Strings now just returns the values (empty when absent), making the common optional-list reads one clean expression. The few required-param callers (scrobble, createShare) check for emptiness and return the same Subsonic error code 10 as before; their e2e tests now pin that code. Ints and Times keep their contracts by synthesizing ErrMissingParam themselves, so selectedMusicFolderIds is untouched. The jellyfin parseFields helper is inlined away, since ParseFields(p.Strings("fields")...) now compiles directly. * docs(req): clarify Strings returns nil when param is absent --- server/jellyfin/items.go | 11 ++-------- server/jellyfin/playlists.go | 2 +- server/nativeapi/missing.go | 2 +- server/nativeapi/playlists.go | 2 +- server/subsonic/bookmarks.go | 4 ++-- .../e2e/subsonic_media_annotation_test.go | 1 + server/subsonic/e2e/subsonic_sharing_test.go | 1 + server/subsonic/jukebox.go | 4 ++-- server/subsonic/library_scanning.go | 3 ++- server/subsonic/media_annotation.go | 18 +++++++-------- server/subsonic/playlists.go | 4 ++-- server/subsonic/sharing.go | 6 ++--- utils/req/req.go | 22 +++++++++---------- utils/req/req_test.go | 4 +--- 14 files changed, 38 insertions(+), 46 deletions(-) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 6ed24b056..c3c217cfc 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -224,20 +224,13 @@ type itemsQuery struct { albumIds []string } -// parseFields reads the Fields param, accepting both repeated params (Fields=a&Fields=b) and a -// comma-separated value (Fields=a,b), matching real Jellyfin. StringOr would keep only one value. -func parseFields(p *req.Values) dto.Fields { - values, _ := p.Strings("fields") - return dto.ParseFields(values...) -} - // parseItemsQuery also resolves the entity types (inferring them from the parent when // IncludeItemTypes is absent) and the library scope. Query keys are read lowercase because // normalizeQueryKeys folded them (Jellyfin binds case-insensitively). func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQuery { p := req.Params(r) q := itemsQuery{ - fields: parseFields(p), + fields: dto.ParseFields(p.Strings("fields")...), ids: decodedQueryIDs(r, "ids"), rawTypes: p.StringOr("includeitemtypes", ""), search: searchTerm(p), @@ -737,7 +730,7 @@ func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fiel func (api *Router) getItem(w http.ResponseWriter, r *http.Request) { id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) - fields := parseFields(req.Params(r)) + fields := dto.ParseFields(req.Params(r).Strings("fields")...) if item, ok := api.resolveItemByID(r.Context(), id, fields); ok { api.ok(w, r, item) return diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index 4d3959a79..9afee5ac3 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -191,7 +191,7 @@ func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) { return } p := req.Params(r) - fields := parseFields(p) + fields := dto.ParseFields(p.Strings("fields")...) res, err := api.playlistTrackPage(repo, fields, p.IntOr("startindex", 0), p.IntOr("limit", 0)) if err != nil { api.internalError(w, r, err) diff --git a/server/nativeapi/missing.go b/server/nativeapi/missing.go index 2b455e622..0ad9bb0cc 100644 --- a/server/nativeapi/missing.go +++ b/server/nativeapi/missing.go @@ -68,7 +68,7 @@ func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc { ctx := r.Context() p := req.Params(r) - ids, _ := p.Strings("id") + ids := p.Strings("id") var err error if len(ids) == 0 { diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index ea1cf579b..90b2f9e94 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -102,7 +102,7 @@ func deleteFromPlaylist(pls playlists.Playlists) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { p := req.Params(r) playlistId, _ := p.String(":playlistId") - ids, _ := p.Strings("id") + ids := p.Strings("id") err := pls.RemoveTracks(r.Context(), playlistId, ids) if len(ids) == 1 && errors.Is(err, model.ErrNotFound) { log.Warn(r.Context(), "Track not found in playlist", "playlistId", playlistId, "id", ids[0]) diff --git a/server/subsonic/bookmarks.go b/server/subsonic/bookmarks.go index 337712750..4a7ebaa6c 100644 --- a/server/subsonic/bookmarks.go +++ b/server/subsonic/bookmarks.go @@ -103,7 +103,7 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) { func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, _ := p.Strings("id") + ids := p.Strings("id") currentID, _ := p.String("current") position := p.Int64Or("position", 0) @@ -176,7 +176,7 @@ func (api *Router) GetPlayQueueByIndex(r *http.Request) (*responses.Subsonic, er func (api *Router) SavePlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, _ := p.Strings("id") + ids := p.Strings("id") position := p.Int64Or("position", 0) diff --git a/server/subsonic/e2e/subsonic_media_annotation_test.go b/server/subsonic/e2e/subsonic_media_annotation_test.go index ec9b070de..74b5238f2 100644 --- a/server/subsonic/e2e/subsonic_media_annotation_test.go +++ b/server/subsonic/e2e/subsonic_media_annotation_test.go @@ -155,6 +155,7 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() { Expect(resp.Status).To(Equal(responses.StatusFailed)) Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorMissingParameter)) }) }) diff --git a/server/subsonic/e2e/subsonic_sharing_test.go b/server/subsonic/e2e/subsonic_sharing_test.go index 03bf1f80f..0421d96ea 100644 --- a/server/subsonic/e2e/subsonic_sharing_test.go +++ b/server/subsonic/e2e/subsonic_sharing_test.go @@ -109,6 +109,7 @@ var _ = Describe("Sharing Endpoints", Ordered, func() { Expect(resp.Status).To(Equal(responses.StatusFailed)) Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorMissingParameter)) }) It("updateShare returns error when id parameter is missing", func() { diff --git a/server/subsonic/jukebox.go b/server/subsonic/jukebox.go index c4bc643ab..d8bf53360 100644 --- a/server/subsonic/jukebox.go +++ b/server/subsonic/jukebox.go @@ -68,7 +68,7 @@ func (api *Router) JukeboxControl(r *http.Request) (*responses.Subsonic, error) case ActionStatus: return createResponse(pb.Status(ctx)) case ActionSet: - ids, _ := p.Strings("id") + ids := p.Strings("id") return createResponse(pb.Set(ctx, ids)) case ActionStart: return createResponse(pb.Start(ctx)) @@ -82,7 +82,7 @@ func (api *Router) JukeboxControl(r *http.Request) (*responses.Subsonic, error) offset := p.IntOr("offset", 0) return createResponse(pb.Skip(ctx, index, offset)) case ActionAdd: - ids, _ := p.Strings("id") + ids := p.Strings("id") return createResponse(pb.Add(ctx, ids)) case ActionClear: return createResponse(pb.Clear(ctx)) diff --git a/server/subsonic/library_scanning.go b/server/subsonic/library_scanning.go index e6f64456d..9630425d2 100644 --- a/server/subsonic/library_scanning.go +++ b/server/subsonic/library_scanning.go @@ -45,7 +45,8 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) { // Parse optional target parameters for selective scanning var targets []model.ScanTarget - if targetParams, err := p.Strings("target"); err == nil && len(targetParams) > 0 { + if targetParams := p.Strings("target"); len(targetParams) > 0 { + var err error targets, err = model.ParseTargets(targetParams) if err != nil { return nil, newError(responses.ErrorGeneric, fmt.Sprintf("Invalid target parameter: %v", err)) diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go index 27170c11b..cfbff3ecb 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -71,9 +71,9 @@ func (api *Router) setRating(ctx context.Context, id string, rating int) error { func (api *Router) Star(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, _ := p.Strings("id") - albumIds, _ := p.Strings("albumId") - artistIds, _ := p.Strings("artistId") + ids := p.Strings("id") + albumIds := p.Strings("albumId") + artistIds := p.Strings("artistId") if len(ids)+len(albumIds)+len(artistIds) == 0 { return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing") } @@ -90,9 +90,9 @@ func (api *Router) Star(r *http.Request) (*responses.Subsonic, error) { func (api *Router) Unstar(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, _ := p.Strings("id") - albumIds, _ := p.Strings("albumId") - artistIds, _ := p.Strings("artistId") + ids := p.Strings("id") + albumIds := p.Strings("albumId") + artistIds := p.Strings("artistId") if len(ids)+len(albumIds)+len(artistIds) == 0 { return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing") } @@ -163,9 +163,9 @@ func (api *Router) setStar(ctx context.Context, star bool, ids ...string) error func (api *Router) Scrobble(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, err := p.Strings("id") - if err != nil { - return nil, err + ids := p.Strings("id") + if len(ids) == 0 { + return nil, newError(responses.ErrorMissingParameter, "missing parameter: 'id'") } times, _ := p.Times("time") if len(times) > 0 && len(times) != len(ids) { diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index c58fb9ab9..17ba1b2c9 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -62,7 +62,7 @@ func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subso func (api *Router) CreatePlaylist(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() p := req.Params(r) - songIds, _ := p.Strings("songId") + songIds := p.Strings("songId") playlistId, _ := p.String("playlistId") name, _ := p.String("name") if playlistId == "" && name == "" { @@ -99,7 +99,7 @@ func (api *Router) UpdatePlaylist(r *http.Request) (*responses.Subsonic, error) if err != nil { return nil, err } - songsToAdd, _ := p.Strings("songIdToAdd") + songsToAdd := p.Strings("songIdToAdd") songIndexesToRemove, _ := p.Ints("songIndexToRemove") var plsName *string if s, err := p.String("name"); err == nil { diff --git a/server/subsonic/sharing.go b/server/subsonic/sharing.go index a9ccfdca4..540ae79d7 100644 --- a/server/subsonic/sharing.go +++ b/server/subsonic/sharing.go @@ -52,9 +52,9 @@ func (api *Router) buildShare(r *http.Request, share model.Share) responses.Shar func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, err := p.Strings("id") - if err != nil { - return nil, err + ids := p.Strings("id") + if len(ids) == 0 { + return nil, newError(responses.ErrorMissingParameter, "missing parameter: 'id'") } description, _ := p.String("description") diff --git a/utils/req/req.go b/utils/req/req.go index 2757fc3f5..861cca9f7 100644 --- a/utils/req/req.go +++ b/utils/req/req.go @@ -60,12 +60,10 @@ func (r *Values) StringOr(param, def string) string { return v } -func (r *Values) Strings(param string) ([]string, error) { - values := r.URL.Query()[param] - if len(values) == 0 { - return nil, newError(ErrMissingParam, param) - } - return values, nil +// Strings returns all occurrences of the param, or a nil (empty) slice when absent. Callers that +// require the param should check for emptiness themselves. +func (r *Values) Strings(param string) []string { + return r.URL.Query()[param] } func (r *Values) TimeOr(param string, def time.Time) time.Time { @@ -85,9 +83,9 @@ func (r *Values) TimeOr(param string, def time.Time) time.Time { } func (r *Values) Times(param string) ([]time.Time, error) { - pStr, err := r.Strings(param) - if err != nil { - return nil, err + pStr := r.Strings(param) + if len(pStr) == 0 { + return nil, newError(ErrMissingParam, param) } times := make([]time.Time, len(pStr)) for i, t := range pStr { @@ -139,9 +137,9 @@ func (r *Values) Int64Or(param string, def int64) int64 { } func (r *Values) Ints(param string) ([]int, error) { - pStr, err := r.Strings(param) - if err != nil { - return nil, err + pStr := r.Strings(param) + if len(pStr) == 0 { + return nil, newError(ErrMissingParam, param) } ints := make([]int, 0, len(pStr)) for _, s := range pStr { diff --git a/utils/req/req_test.go b/utils/req/req_test.go index d76b3b934..5f9de8483 100644 --- a/utils/req/req_test.go +++ b/utils/req/req_test.go @@ -60,9 +60,7 @@ var _ = Describe("Request Helpers", func() { }) It("returns empty array if param does not exist", func() { - v, err := r.Strings("xx") - Expect(err).To(MatchError(req.ErrMissingParam)) - Expect(v).To(BeEmpty()) + Expect(r.Strings("xx")).To(BeEmpty()) }) }) From 59f1b4206c9fd40b10b1bb32938b546c646c8034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 19:36:12 -0400 Subject: [PATCH 08/19] fix(jellyfin): serve playlist covers regardless of visibility (#5813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image endpoint only served a private playlist's cover when the request carried a token identifying its owner or an admin. But clients fetch cover URLs without credentials — real Jellyfin's image routes are anonymous — so every private playlist rendered the generic placeholder in Jellyfin clients (observed in production), while the same covers displayed fine through the always-authenticated Subsonic/native APIs. Drop the gate and serve playlist covers like album/artist/track artwork: playlist ids are unguessable without credentials, so anonymous access does not meaningfully expose private playlist contents. --- server/jellyfin/e2e/images_test.go | 34 ++++++++---------------------- server/jellyfin/images.go | 16 +++++--------- server/jellyfin/images_test.go | 17 ++------------- server/jellyfin/middlewares.go | 2 +- 4 files changed, 17 insertions(+), 52 deletions(-) diff --git a/server/jellyfin/e2e/images_test.go b/server/jellyfin/e2e/images_test.go index 0c53d75f3..7fb85cba8 100644 --- a/server/jellyfin/e2e/images_test.go +++ b/server/jellyfin/e2e/images_test.go @@ -28,9 +28,9 @@ var _ = Describe("Item images", func() { Expect(artworkSpy.lastID).To(ContainSubstring(id)) }) - It("resolves a private playlist's cover for its owner under an elevated context", func() { - // The route carries no user in ctx (public); the owner is identified by the request token, - // and resolution then runs elevated so the visibility filter doesn't eat the cover. + It("resolves a private playlist's cover under an elevated context", func() { + // The route carries no user in ctx (public); resolution runs elevated so the visibility + // filter doesn't eat the cover. plID := createPlaylist("Private Mix", nil) w := get("/Items/" + enc(plID) + "/Images/Primary") Expect(w.Code).To(Equal(http.StatusOK)) @@ -48,27 +48,11 @@ var _ = Describe("Item images", func() { Expect(w.Body.String()).To(Equal("IMG")) }) - Describe("private playlist covers", func() { - It("does not resolve a private playlist's cover for an unauthenticated caller", func() { - plID := createPlaylist("Secret Mix", nil) // owned by admin, private - w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "") - Expect(w.Code).To(Equal(http.StatusOK)) // placeholder, not an auth error - Expect(artworkSpy.lastID).ToNot(ContainSubstring(plID)) - }) - - It("does not resolve a private playlist's cover for another user", func() { - plID := createPlaylist("Secret Mix", nil) - w := getAs(regularUser, "/Items/"+enc(plID)+"/Images/Primary") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(artworkSpy.lastID).ToNot(ContainSubstring(plID)) - }) - - It("resolves a public playlist's cover for anyone", func() { - plID := createPlaylist("Shared Mix", nil) - Expect(post("/Playlists/"+enc(plID), `{"IsPublic":true}`).Code).To(Equal(http.StatusNoContent)) - w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(artworkSpy.lastID).To(ContainSubstring(plID)) - }) + // Covers are served regardless of playlist visibility — see getItemImage for the rationale. + It("resolves a private playlist's cover for an unauthenticated caller", func() { + plID := createPlaylist("Secret Mix", nil) // owned by admin, private + w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(artworkSpy.lastID).To(ContainSubstring(plID)) }) }) diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go index 722e828c5..0ec34f491 100644 --- a/server/jellyfin/images.go +++ b/server/jellyfin/images.go @@ -25,14 +25,13 @@ import ( ) func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { - // Public endpoint (no user in ctx): library artwork isn't user-sensitive, so resolution runs - // under an elevated context to bypass the persistence visibility filter; playlist access is - // gated inside resolveArtworkID. + // Public endpoint, like real Jellyfin's image routes: clients fetch cover URLs without credentials + // and item ids are unguessable, so resolution runs elevated to bypass the visibility filter. ctx := request.WithUser(r.Context(), model.User{IsAdmin: true}) itemId := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) size, _ := strconv.Atoi(r.URL.Query().Get("maxwidth")) - artID := api.resolveArtworkID(ctx, r, itemId) + artID := api.resolveArtworkID(ctx, itemId) reader, _, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) switch { case errors.Is(err, context.Canceled): @@ -49,7 +48,7 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { // resolveArtworkID maps a Jellyfin item id to a Navidrome ArtworkID, probing // album -> artist -> media file -> playlist. -func (api *Router) resolveArtworkID(ctx context.Context, r *http.Request, itemId string) string { +func (api *Router) resolveArtworkID(ctx context.Context, itemId string) string { if al, err := api.ds.Album(ctx).Get(itemId); err == nil { return al.CoverArtID().String() } @@ -60,12 +59,7 @@ func (api *Router) resolveArtworkID(ctx context.Context, r *http.Request, itemId return mf.CoverArtID().String() } if pl, err := api.ds.Playlist(ctx).Get(itemId); err == nil { - // Playlist covers are user-scoped: serve a private one only for a public playlist or a - // token identifying its owner/an admin, so this public route can't probe others' covers. - u, ok := api.userFromToken(r) - if pl.Public || (ok && (u.IsAdmin || pl.OwnerID == u.ID)) { - return pl.CoverArtID().String() - } + return pl.CoverArtID().String() } return (model.ArtworkID{}).String() } diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go index 8099fcf94..e435b99fd 100644 --- a/server/jellyfin/images_test.go +++ b/server/jellyfin/images_test.go @@ -85,20 +85,7 @@ var _ = Describe("Images", func() { Expect(w.Header().Get("Content-Type")).To(Equal("image/png")) }) - It("resolves a public playlist id to its cover artwork", func() { - ds := &tests.MockDataStore{} - ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", Public: true}}) - fa := &fakeArtwork{} - api := &Router{ds: ds, artwork: fa} - - w, r := newImageRequest(dto.EncodeID("pl1")) - api.getItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fa.recvId).To(ContainSubstring("pl1")) - }) - - It("serves the placeholder, not the cover, for a private playlist and an anonymous caller", func() { + It("resolves a playlist's cover regardless of visibility, even for an anonymous caller", func() { ds := &tests.MockDataStore{} ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", OwnerID: "someone"}}) fa := &fakeArtwork{} @@ -108,7 +95,7 @@ var _ = Describe("Images", func() { api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fa.recvId).ToNot(ContainSubstring("pl1")) + Expect(fa.recvId).To(ContainSubstring("pl1")) }) // This endpoint is public (no user in the request), so artwork must be resolved under an diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go index 840143ac8..c90f9c088 100644 --- a/server/jellyfin/middlewares.go +++ b/server/jellyfin/middlewares.go @@ -152,7 +152,7 @@ func tokenFromRequest(r *http.Request) string { } // userFromToken resolves the user for the request's token; ok is false for a missing/invalid token -// or unknown subject. Used by authenticate and by public routes that optionally identify the caller. +// or unknown subject. func (api *Router) userFromToken(r *http.Request) (model.User, bool) { token := tokenFromRequest(r) if token == "" { From 7234ea23b76c228385922cc674f7c56a1d55b3f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 20:26:14 -0400 Subject: [PATCH 09/19] feat(jellyfin): expose NormalizationGain from ReplayGain tags (#5815) * feat(jellyfin): expose NormalizationGain from ReplayGain tags Adds NormalizationGain and AlbumNormalizationGain to Audio BaseItemDtos, sourced from the scanner's ReplayGain values (REPLAYGAIN_* tags, with R128_* already converted to the same -18 LUFS reference). Same wire contract as real Jellyfin: PascalCase keys, omitted when absent, no Fields gating. Album items intentionally omit the field: model.Album has no gain column and Feishin reads gain from song DTOs. * test(jellyfin): e2e coverage for NormalizationGain fields * test(jellyfin): drop redundant NormalizationGain passthrough spec The JSON-casing spec already proves the mapped values (the substring "NormalizationGain":-3.5 can only appear if the passthrough worked), so the direct-struct spec added no coverage. * style(jellyfin): use ASCII punctuation in gain comment --- server/jellyfin/dto/dto.go | 30 ++++++++++++++------------- server/jellyfin/dto/mappers.go | 3 +++ server/jellyfin/dto/mappers_test.go | 16 ++++++++++++++ server/jellyfin/e2e/browsing_test.go | 16 ++++++++++++++ server/jellyfin/e2e/e2e_suite_test.go | 4 +++- 5 files changed, 54 insertions(+), 15 deletions(-) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 2a817739b..c8f7129cb 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -61,20 +61,22 @@ type BaseItemDto struct { PremiereDate *string `json:"PremiereDate,omitempty"` // DateCreated is the ISO 8601 date the item was added to the library; clients show it as // "Date Added" and sort "Recently Added" by it. - DateCreated string `json:"DateCreated,omitempty"` - Album string `json:"Album,omitempty"` - AlbumId string `json:"AlbumId,omitempty"` - AlbumArtist string `json:"AlbumArtist,omitempty"` - AlbumArtists []NameGuidPair `json:"AlbumArtists,omitempty"` - AlbumPrimaryImageTag string `json:"AlbumPrimaryImageTag,omitempty"` - Artists []string `json:"Artists,omitempty"` - ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` - Genres []string `json:"Genres,omitempty"` - GenreItems []NameGuidPair `json:"GenreItems,omitempty"` - ChildCount *int `json:"ChildCount,omitempty"` - SongCount *int `json:"SongCount,omitempty"` - AlbumCount *int `json:"AlbumCount,omitempty"` - ImageTags map[string]string `json:"ImageTags,omitempty"` + DateCreated string `json:"DateCreated,omitempty"` + Album string `json:"Album,omitempty"` + AlbumId string `json:"AlbumId,omitempty"` + AlbumArtist string `json:"AlbumArtist,omitempty"` + AlbumArtists []NameGuidPair `json:"AlbumArtists,omitempty"` + AlbumPrimaryImageTag string `json:"AlbumPrimaryImageTag,omitempty"` + Artists []string `json:"Artists,omitempty"` + ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` + Genres []string `json:"Genres,omitempty"` + GenreItems []NameGuidPair `json:"GenreItems,omitempty"` + NormalizationGain *float64 `json:"NormalizationGain,omitempty"` + AlbumNormalizationGain *float64 `json:"AlbumNormalizationGain,omitempty"` + ChildCount *int `json:"ChildCount,omitempty"` + SongCount *int `json:"SongCount,omitempty"` + AlbumCount *int `json:"AlbumCount,omitempty"` + ImageTags map[string]string `json:"ImageTags,omitempty"` // ImageBlurHashes is keyed by image type (e.g. "Primary") then image tag. Finamp uses it as a // de-dup key for image downloads (and a placeholder); absent, it warns the server isn't // calculating blurhashes. diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index dc9bc5fbd..bdb0a1e37 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -171,6 +171,9 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { if mf.AlbumArtistID != "" { item.AlbumArtists = []NameGuidPair{{Name: mf.AlbumArtist, Id: EncodeID(mf.AlbumArtistID)}} } + // dB to apply at the RG2 -18 LUFS reference, same convention real Jellyfin uses; no conversion. + item.NormalizationGain = mf.RGTrackGain + item.AlbumNormalizationGain = mf.RGAlbumGain if mf.Year > 0 { item.ProductionYear = new(mf.Year) } diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 6bfc9d95a..8867a5e73 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -125,6 +125,22 @@ var _ = Describe("mappers", func() { Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID("ar-delasoul")}})) }) + It("serializes normalization gains with Jellyfin's exact key casing", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", + RGTrackGain: new(-3.5), RGAlbumGain: new(-4.25)} + b, err := json.Marshal(SongToBaseItem(mf, nil)) + Expect(err).ToNot(HaveOccurred()) + Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-3.5`)) + Expect(string(b)).To(ContainSubstring(`"AlbumNormalizationGain":-4.25`)) + }) + + It("omits normalization gains when the file has no ReplayGain tags", func() { + b, err := json.Marshal(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil)) + Expect(err).ToNot(HaveOccurred()) + // Substring check covers both keys (AlbumNormalizationGain contains NormalizationGain). + Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) + }) + It("builds a MediaSourceInfo from a media file", func() { mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} src := MediaSourceFromMediaFile(mf) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 382ec9406..a0632994e 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -415,6 +415,22 @@ var _ = Describe("Browsing", func() { Expect(item.AlbumArtists[0].Name).To(Equal("Miles Davis")) }) + It("exposes NormalizationGain and AlbumNormalizationGain from ReplayGain tags", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("Stairway To Heaven"))), &item) + Expect(item.NormalizationGain).ToNot(BeNil()) + Expect(*item.NormalizationGain).To(BeNumerically("~", -3.5, 0.001)) + Expect(item.AlbumNormalizationGain).ToNot(BeNil()) + Expect(*item.AlbumNormalizationGain).To(BeNumerically("~", -4.25, 0.001)) + }) + + It("omits normalization gains for files without ReplayGain tags", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("So What"))), &item) + Expect(item.NormalizationGain).To(BeNil()) + Expect(item.AlbumNormalizationGain).To(BeNil()) + }) + It("resolves an artist", func() { var item dto.BaseItemDto parseInto(get("/Items/"+enc(artistID("Miles Davis"))), &item) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 445566387..5a4c8c031 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -121,7 +121,9 @@ func buildTestFS() storagetest.FakeFS { "Rock/The Beatles/Abbey Road/02 - Come Together.mp3": abbeyRoad(track(2, "Come Together")), "Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")), "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven", _t{ - "lyrics:eng": "[00:01.00]There's a lady who's sure\n[00:05.50]All that glitters is gold", + "lyrics:eng": "[00:01.00]There's a lady who's sure\n[00:05.50]All that glitters is gold", + "replaygain_track_gain": "-3.50 dB", + "replaygain_album_gain": "-4.25 dB", })), "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")), "Pop/Solo Artist/Singles/01 - Standalone Track.mp3": singles(track(1, "Standalone Track")), From 4efd92cf835bd7a8e3c971b5b87e834b0f32ee6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 22:19:29 -0400 Subject: [PATCH 10/19] feat(server): expose album-level ReplayGain in albums (#5816) * feat(model): aggregate album ReplayGain in ToAlbum * feat(db): add nullable album ReplayGain columns with backfill * feat(persistence): persist album ReplayGain fields * feat(jellyfin): expose album NormalizationGain from ReplayGain * refactor(model): lazy-init mostFrequentPtr map; clean up RG test rows * fix(db): backfill album ReplayGain with most-frequent value, not max A plain max() picked a minority outlier that diverged from MediaFiles.ToAlbum (which uses the most-frequent value), and GetTouchedAlbums never re-derives an unchanged album, so the wrong value would persist. Reproduce the modal aggregation via grouped CTEs, which also groups media_file once instead of a per-album correlated scan. * refactor(model): return an owned pointer from mostFrequentPtr Avoid aliasing a MediaFile field so the resulting Album is independent of the source slice. --- .../20260719005427_add_album_replaygain.go | 44 +++++++++++++++++++ model/album.go | 2 + model/mediafile.go | 32 ++++++++++++++ model/mediafile_test.go | 29 ++++++++++++ persistence/album_repository.go | 6 +++ persistence/album_repository_test.go | 27 ++++++++++++ server/jellyfin/dto/mappers.go | 3 ++ server/jellyfin/dto/mappers_test.go | 15 +++++++ 8 files changed, 158 insertions(+) create mode 100644 db/migrations/20260719005427_add_album_replaygain.go diff --git a/db/migrations/20260719005427_add_album_replaygain.go b/db/migrations/20260719005427_add_album_replaygain.go new file mode 100644 index 000000000..79c8b8b3e --- /dev/null +++ b/db/migrations/20260719005427_add_album_replaygain.go @@ -0,0 +1,44 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddAlbumReplaygain, downAddAlbumReplaygain) +} + +func upAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error { + // Backfill the most-frequent non-null value per album, matching MediaFiles.ToAlbum. A plain + // max() would pick a minority outlier, and GetTouchedAlbums never re-derives an unchanged album, + // so a wrong value would persist. The CTEs group media_file once instead of scanning it per album. + _, err := tx.ExecContext(ctx, ` +ALTER TABLE album ADD COLUMN rg_album_gain real; +ALTER TABLE album ADD COLUMN rg_album_peak real; + +WITH gain_mode AS ( + SELECT album_id, rg_album_gain AS val, + row_number() OVER (PARTITION BY album_id ORDER BY count(*) DESC, rg_album_gain) AS rn + FROM media_file WHERE rg_album_gain IS NOT NULL + GROUP BY album_id, rg_album_gain +), +peak_mode AS ( + SELECT album_id, rg_album_peak AS val, + row_number() OVER (PARTITION BY album_id ORDER BY count(*) DESC, rg_album_peak) AS rn + FROM media_file WHERE rg_album_peak IS NOT NULL + GROUP BY album_id, rg_album_peak +) +UPDATE album SET + rg_album_gain = (SELECT val FROM gain_mode WHERE gain_mode.album_id = album.id AND gain_mode.rn = 1), + rg_album_peak = (SELECT val FROM peak_mode WHERE peak_mode.album_id = album.id AND peak_mode.rn = 1); + `) + return err +} + +func downAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error { + // This code is executed when the migration is rolled back. + return nil +} diff --git a/model/album.go b/model/album.go index ade7f6ee0..16727ea39 100644 --- a/model/album.go +++ b/model/album.go @@ -49,6 +49,8 @@ type Album struct { MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"` FolderIDs []string `structs:"folder_ids" json:"-" hash:"set"` // All folders that contain media_files for this album ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"` + RGAlbumGain *float64 `structs:"rg_album_gain" json:"rgAlbumGain"` + RGAlbumPeak *float64 `structs:"rg_album_peak" json:"rgAlbumPeak"` // External metadata fields Description string `structs:"description" json:"description,omitempty" hash:"ignore"` diff --git a/model/mediafile.go b/model/mediafile.go index 90db80f71..910cd998d 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -314,6 +314,8 @@ func (mfs MediaFiles) ToAlbum() Album { originalYears := make([]int, 0, len(mfs)) originalDates := make([]string, 0, len(mfs)) releaseDates := make([]string, 0, len(mfs)) + rgAlbumGains := make([]*float64, 0, len(mfs)) + rgAlbumPeaks := make([]*float64, 0, len(mfs)) tags := make(TagList, 0, len(mfs[0].Tags)*len(mfs)) a.Missing = true @@ -344,6 +346,8 @@ func (mfs MediaFiles) ToAlbum() Album { originalYears = append(originalYears, m.OriginalYear) originalDates = append(originalDates, m.OriginalDate) releaseDates = append(releaseDates, m.ReleaseDate) + rgAlbumGains = append(rgAlbumGains, m.RGAlbumGain) + rgAlbumPeaks = append(rgAlbumPeaks, m.RGAlbumPeak) comments = append(comments, m.Comment) mbzAlbumIds = append(mbzAlbumIds, m.MbzAlbumID) mbzReleaseGroupIds = append(mbzReleaseGroupIds, m.MbzReleaseGroupID) @@ -378,6 +382,8 @@ func (mfs MediaFiles) ToAlbum() Album { a.Comment, _ = allOrNothing(comments) a.MbzAlbumID = slice.MostFrequent(mbzAlbumIds) a.MbzReleaseGroupID = slice.MostFrequent(mbzReleaseGroupIds) + a.RGAlbumGain = mostFrequentPtr(rgAlbumGains) + a.RGAlbumPeak = mostFrequentPtr(rgAlbumPeaks) fixAlbumArtist(&a) return a @@ -407,6 +413,32 @@ func minMax(items []int) (int, int) { return mn, mx } +// mostFrequentPtr returns a pointer to the most common non-nil value, or nil if +// none. It counts by dereferenced value so a genuine 0.0 is a real candidate +// (slice.MostFrequent skips the zero value and compares pointers by identity). +func mostFrequentPtr(items []*float64) *float64 { + var counts map[float64]int + var best float64 + var bestCount int + for _, it := range items { + if it == nil { + continue + } + if counts == nil { + counts = map[float64]int{} + } + counts[*it]++ + if counts[*it] > bestCount { + bestCount = counts[*it] + best = *it + } + } + if bestCount == 0 { + return nil + } + return &best +} + func newer(t1, t2 time.Time) time.Time { if t1.After(t2) { return t1 diff --git a/model/mediafile_test.go b/model/mediafile_test.go index c94653530..6085ccf25 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -268,6 +268,35 @@ var _ = Describe("MediaFiles", func() { }) }) }) + Context("ReplayGain", func() { + It("picks the most frequent non-nil album gain and peak", func() { + mfs := MediaFiles{ + {Path: "a", RGAlbumGain: new(-8.0), RGAlbumPeak: new(0.9)}, + {Path: "b", RGAlbumGain: new(-8.0), RGAlbumPeak: new(0.9)}, + {Path: "c", RGAlbumGain: new(-5.0), RGAlbumPeak: new(1.0)}, + } + album := mfs.ToAlbum() + Expect(album.RGAlbumGain).ToNot(BeNil()) + Expect(*album.RGAlbumGain).To(Equal(-8.0)) + Expect(album.RGAlbumPeak).ToNot(BeNil()) + Expect(*album.RGAlbumPeak).To(Equal(0.9)) + }) + It("keeps a genuine 0.0 gain instead of dropping it", func() { + mfs := MediaFiles{ + {Path: "a", RGAlbumGain: new(0.0)}, + {Path: "b", RGAlbumGain: new(0.0)}, + } + album := mfs.ToAlbum() + Expect(album.RGAlbumGain).ToNot(BeNil()) + Expect(*album.RGAlbumGain).To(Equal(0.0)) + }) + It("leaves gain and peak nil when no track has a value", func() { + mfs := MediaFiles{{Path: "a"}, {Path: "b"}} + album := mfs.ToAlbum() + Expect(album.RGAlbumGain).To(BeNil()) + Expect(album.RGAlbumPeak).To(BeNil()) + }) + }) Context("Participants", func() { var album Album BeforeEach(func() { diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 6ebbd9202..53e8efe32 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -31,6 +31,10 @@ type dbAlbum struct { Participants string `structs:"-" json:"-"` Tags string `structs:"-" json:"-"` FolderIDs string `structs:"-" json:"-"` + // dbx maps columns to fields by name; RGAlbumGain doesn't convert to + // rg_album_gain, so shim fields carry the read and PostScan copies them over. + RgAlbumGain *float64 `structs:"-" json:"-"` + RgAlbumPeak *float64 `structs:"-" json:"-"` } func (a *dbAlbum) PostScan() error { @@ -58,6 +62,8 @@ func (a *dbAlbum) PostScan() error { } a.Album.FolderIDs = ids } + a.Album.RGAlbumGain = a.RgAlbumGain + a.Album.RGAlbumPeak = a.RgAlbumPeak return nil } diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 64ff0095e..761255b7d 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -890,6 +890,33 @@ var _ = Describe("AlbumRepository", func() { Expect(albums[0].ID).To(Equal("a1")) }) }) + + Describe("ReplayGain", func() { + BeforeEach(func() { + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"rg-1", "rg-2"}})) + }) + }) + It("round-trips album ReplayGain gain and peak", func() { + Expect(albumRepo.Put(&model.Album{ + ID: "rg-1", Name: "rg", LibraryID: 1, + RGAlbumGain: new(-7.5), RGAlbumPeak: new(0.98), + })).To(Succeed()) + got, err := albumRepo.Get("rg-1") + Expect(err).ToNot(HaveOccurred()) + Expect(got.RGAlbumGain).ToNot(BeNil()) + Expect(*got.RGAlbumGain).To(Equal(-7.5)) + Expect(got.RGAlbumPeak).ToNot(BeNil()) + Expect(*got.RGAlbumPeak).To(Equal(0.98)) + }) + It("reads nil when ReplayGain is unset", func() { + Expect(albumRepo.Put(&model.Album{ID: "rg-2", Name: "rg2", LibraryID: 1})).To(Succeed()) + got, err := albumRepo.Get("rg-2") + Expect(err).ToNot(HaveOccurred()) + Expect(got.RGAlbumGain).To(BeNil()) + Expect(got.RGAlbumPeak).To(BeNil()) + }) + }) }) func _p(id, name string, sortName ...string) model.Participant { diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index bdb0a1e37..036dfbd91 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -232,6 +232,9 @@ func AlbumToBaseItem(al model.Album) BaseItemDto { item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name}) } } + // The album's own ReplayGain gain (dB at the RG2 -18 LUFS reference) — same + // convention as tracks; clients read it off the album item as NormalizationGain. + item.NormalizationGain = al.RGAlbumGain return item } diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 8867a5e73..c986e7f81 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -260,6 +260,21 @@ var _ = Describe("mappers", func() { Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) + It("sets NormalizationGain on the album from its ReplayGain", func() { + al := model.Album{ID: "al1", Name: "Album", RGAlbumGain: new(-6.0)} + b, err := json.Marshal(AlbumToBaseItem(al)) + Expect(err).ToNot(HaveOccurred()) + Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-6`)) + // Real Jellyfin never sets AlbumNormalizationGain on an album item. + Expect(string(b)).ToNot(ContainSubstring("AlbumNormalizationGain")) + }) + + It("omits NormalizationGain when the album has no ReplayGain", func() { + b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: "al1", Name: "Album"})) + Expect(err).ToNot(HaveOccurred()) + Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) + }) + It("maps an artist to a MusicArtist folder item", func() { ar := model.Artist{ID: "art-1", Name: "AA", AlbumCount: 2, SongCount: 20} item := ArtistToBaseItem(ar) From b27d6f61ae7813d47eb4479637187bf26ab35adb Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 18 Jul 2026 22:59:53 -0400 Subject: [PATCH 11/19] fix(db): make album ReplayGain backfill scale to large libraries The windowed-CTE backfill was compiled as a correlated scalar subquery re-evaluated once per album (a full media_file group-by + window sort each time), which never finished on a large library. Stage the ReplayGain-bearing rows into an indexed temp table once and update only the affected albums: ~14s on a 727MB / 6945-album library, vs. effectively never. --- .../20260719005427_add_album_replaygain.go | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/db/migrations/20260719005427_add_album_replaygain.go b/db/migrations/20260719005427_add_album_replaygain.go index 79c8b8b3e..7f5665947 100644 --- a/db/migrations/20260719005427_add_album_replaygain.go +++ b/db/migrations/20260719005427_add_album_replaygain.go @@ -12,28 +12,25 @@ func init() { } func upAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error { - // Backfill the most-frequent non-null value per album, matching MediaFiles.ToAlbum. A plain - // max() would pick a minority outlier, and GetTouchedAlbums never re-derives an unchanged album, - // so a wrong value would persist. The CTEs group media_file once instead of scanning it per album. + // Backfill the most-frequent value per album (matching MediaFiles.ToAlbum), staging RG-bearing rows + // into an indexed temp table — a correlated subquery over a windowed CTE re-scans media_file per album. _, err := tx.ExecContext(ctx, ` ALTER TABLE album ADD COLUMN rg_album_gain real; ALTER TABLE album ADD COLUMN rg_album_peak real; -WITH gain_mode AS ( - SELECT album_id, rg_album_gain AS val, - row_number() OVER (PARTITION BY album_id ORDER BY count(*) DESC, rg_album_gain) AS rn - FROM media_file WHERE rg_album_gain IS NOT NULL - GROUP BY album_id, rg_album_gain -), -peak_mode AS ( - SELECT album_id, rg_album_peak AS val, - row_number() OVER (PARTITION BY album_id ORDER BY count(*) DESC, rg_album_peak) AS rn - FROM media_file WHERE rg_album_peak IS NOT NULL - GROUP BY album_id, rg_album_peak -) +CREATE TEMP TABLE _rg_backfill AS + SELECT album_id, rg_album_gain, rg_album_peak FROM media_file + WHERE rg_album_gain IS NOT NULL OR rg_album_peak IS NOT NULL; +CREATE INDEX _rg_backfill_album ON _rg_backfill(album_id); + UPDATE album SET - rg_album_gain = (SELECT val FROM gain_mode WHERE gain_mode.album_id = album.id AND gain_mode.rn = 1), - rg_album_peak = (SELECT val FROM peak_mode WHERE peak_mode.album_id = album.id AND peak_mode.rn = 1); + rg_album_gain = (SELECT rg_album_gain FROM _rg_backfill WHERE _rg_backfill.album_id = album.id AND rg_album_gain IS NOT NULL + GROUP BY rg_album_gain ORDER BY count(*) DESC, rg_album_gain LIMIT 1), + rg_album_peak = (SELECT rg_album_peak FROM _rg_backfill WHERE _rg_backfill.album_id = album.id AND rg_album_peak IS NOT NULL + GROUP BY rg_album_peak ORDER BY count(*) DESC, rg_album_peak LIMIT 1) +WHERE album.id IN (SELECT album_id FROM _rg_backfill); + +DROP TABLE _rg_backfill; `) return err } From 5927e693d128cb3c830f6d87b59bfd5128d35dfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 19 Jul 2026 12:39:31 -0400 Subject: [PATCH 12/19] feat(jellyfin): filter items by year and record label (#5817) * feat(persistence): add AlbumRepository.GetYears for distinct album years * test(persistence): verify GetYears de-duplicates repeated years Regression test that adds two albums with the same non-zero max_year (2005) and verifies that GetYears() returns that year exactly once, ensuring the SQL DISTINCT clause is applied correctly. Catches any future removal of DISTINCT from the GetYears query. * feat(jellyfin): add legacy /Items/Filters endpoint (genres + years) * feat(jellyfin): add /Studios endpoint from record label tags * refactor(persistence): drop duplicate columns in tagRepository.GetAll * fix(jellyfin): exclude missing albums from filter years GetYears only filtered max_year > 0, so albums whose files were all removed (missing=true, kept when Scanner.PurgeMissing=never) contributed stale years to /Items/Filters. Filter them out like the normal album listings do. Also return an empty slice from the MockAlbumRepo to match the real repository. * feat(jellyfin): filter /Items by Years= * feat(jellyfin): filter /Items by StudioIds= (record labels) * feat(jellyfin): scope filter and studio lists to ParentId library * refactor(jellyfin): extract parentIDScope and libraryScopeFilter helpers Collapse the three inline resolveLibraryScope(dto.DecodeID(parentid)) call sites and the duplicated empty-scope guard into two small helpers, so the empty-scope=unrestricted contract lives in one place. Reuse the existing names() helper in the Years= e2e test. * feat(jellyfin): expose record labels as album Studios Add a Studios field to the album BaseItemDto, populated from the record-label tags and gated behind Fields=Studios (matching Jellyfin's ItemFields convention). Studio ids reuse the record-label tag identity, so they round-trip with the /Studios list and the StudioIds= filter. Real Jellyfin leaves Studios empty for music; Feishin reads it as the album's record label. --- model/album.go | 1 + model/tag.go | 1 + persistence/album_repository.go | 15 +++++++ persistence/album_repository_test.go | 60 +++++++++++++++++++++++++ persistence/tag_repository.go | 11 ++++- server/filter/filters.go | 25 +++++++++-- server/jellyfin/api.go | 2 + server/jellyfin/browsing.go | 44 +++++++++++++++++- server/jellyfin/browsing_test.go | 48 ++++++++++++++++++++ server/jellyfin/dto/dto.go | 10 +++++ server/jellyfin/dto/filters_test.go | 22 +++++++++ server/jellyfin/dto/mappers.go | 19 +++++++- server/jellyfin/dto/mappers_test.go | 32 ++++++++++--- server/jellyfin/e2e/browsing_test.go | 65 +++++++++++++++++++++++++++ server/jellyfin/e2e/e2e_suite_test.go | 2 +- server/jellyfin/items.go | 42 ++++++++++++++--- server/jellyfin/library.go | 17 +++++++ server/jellyfin/similar.go | 2 +- tests/mock_album_repo.go | 7 +++ tests/mock_data_store.go | 2 +- tests/mock_genre_repo.go | 10 +++-- tests/mock_tag_repo.go | 24 ++++++++++ 22 files changed, 435 insertions(+), 26 deletions(-) create mode 100644 server/jellyfin/dto/filters_test.go create mode 100644 tests/mock_tag_repo.go diff --git a/model/album.go b/model/album.go index 16727ea39..0afeb2760 100644 --- a/model/album.go +++ b/model/album.go @@ -144,6 +144,7 @@ type AlbumRepository interface { Get(id string) (*Album, error) GetAll(...QueryOptions) (Albums, error) GetCursor(...QueryOptions) (AlbumCursor, error) + GetYears(libraryIDs ...int) ([]int, error) // The following methods are used exclusively by the scanner: Touch(ids ...string) error diff --git a/model/tag.go b/model/tag.go index 02ccac05d..1bc011495 100644 --- a/model/tag.go +++ b/model/tag.go @@ -153,6 +153,7 @@ func (t Tags) Add(name TagName, v string) { type TagRepository interface { Add(libraryID int, tags ...Tag) error UpdateCounts() error + GetAll(name TagName, options ...QueryOptions) (TagList, error) } type TagName string diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 53e8efe32..2bb541003 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -3,6 +3,7 @@ package persistence import ( "context" "encoding/json" + "errors" "fmt" "iter" "maps" @@ -262,6 +263,20 @@ func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumC return wrapAlbumCursor(cursor), nil } +func (r *albumRepository) GetYears(libraryIDs ...int) ([]int, error) { + cond := And{Gt{"max_year": 0}, Eq{"missing": false}} + if len(libraryIDs) > 0 { + cond = append(cond, Eq{"library_id": libraryIDs}) + } + sq := r.applyLibraryFilter(Select("distinct max_year").From("album").Where(cond).OrderBy("max_year")) + years := []int{} + err := r.queryAllSlice(sq, &years) + if err != nil && !errors.Is(err, model.ErrNotFound) { + return nil, err + } + return years, nil +} + func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error { var from dbx.NullStringMap err := r.queryOne(Select(columns...).From(r.tableName).Where(Eq{"id": fromID}), &from) diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 761255b7d..465617b10 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -3,6 +3,7 @@ package persistence import ( "errors" "fmt" + "sort" "time" "github.com/Masterminds/squirrel" @@ -851,6 +852,65 @@ var _ = Describe("AlbumRepository", func() { }) }) + Describe("GetYears", func() { + It("returns distinct album years ascending, excluding zero", func() { + years, err := albumRepo.GetYears() + Expect(err).ToNot(HaveOccurred()) + // Sorted ascending, no duplicates, no zero-year entries. + Expect(sort.IsSorted(sort.IntSlice(years))).To(BeTrue()) + Expect(years).ToNot(ContainElement(0)) + for i := 1; i < len(years); i++ { + Expect(years[i]).To(BeNumerically(">", years[i-1])) // strictly increasing = distinct + } + }) + + It("deduplicates repeated years", func() { + // Regression test: verify that DISTINCT is applied in the SQL. + // Insert two albums with the same non-zero max_year (2005). + album1 := &model.Album{LibraryID: 1, ID: "dedup-test-1", Name: "Album 1", MaxYear: 2005} + album2 := &model.Album{LibraryID: 1, ID: "dedup-test-2", Name: "Album 2", MaxYear: 2005} + Expect(albumRepo.Put(album1)).To(Succeed()) + Expect(albumRepo.Put(album2)).To(Succeed()) + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"dedup-test-1", "dedup-test-2"}})) + }) + + years, err := albumRepo.GetYears() + Expect(err).ToNot(HaveOccurred()) + + // Count occurrences of 2005 in the result + count := 0 + for _, y := range years { + if y == 2005 { + count++ + } + } + Expect(count).To(Equal(1), "year 2005 should appear exactly once despite two albums having it") + }) + + It("scopes years to the given libraries", func() { + all, err := albumRepo.GetYears() + Expect(err).ToNot(HaveOccurred()) + // A library with no albums yields no years. + scoped, err := albumRepo.GetYears(99999) + Expect(err).ToNot(HaveOccurred()) + Expect(scoped).To(BeEmpty()) + Expect(all).ToNot(BeEmpty()) + }) + + It("excludes years that belong only to missing albums", func() { + gone := &model.Album{LibraryID: 1, ID: "missing-year-1", Name: "Gone", MaxYear: 1911, Missing: true} + Expect(albumRepo.Put(gone)).To(Succeed()) + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": "missing-year-1"})) + }) + + years, err := albumRepo.GetYears() + Expect(err).ToNot(HaveOccurred()) + Expect(years).ToNot(ContainElement(1911)) + }) + }) + Describe("wrapAlbumCursor", func() { It("does not panic when the cursor yields a dbAlbum with nil Album", func() { // Simulate what queryWithStableResults does on the rows.Err() path: diff --git a/persistence/tag_repository.go b/persistence/tag_repository.go index 5bb8b3832..f2093c9d8 100644 --- a/persistence/tag_repository.go +++ b/persistence/tag_repository.go @@ -74,13 +74,20 @@ DO UPDATE SET %[1]s_count = excluded.%[1]s_count; return nil } +func (r *tagRepository) GetAll(name model.TagName, options ...model.QueryOptions) (model.TagList, error) { + sq := r.newSelect(options...).Where(Eq{"tag.tag_name": name}) + res := model.TagList{} + err := r.queryAll(sq, &res) + return res, err +} + func (r *tagRepository) purgeUnused() error { - del := Delete(r.tableName).Where(` + del := Delete(r.tableName).Where(` id not in (select jt.value from album left join json_tree(album.tags, '$') as jt where atom is not null and key = 'id' - UNION + UNION select jt.value from media_file left join json_tree(media_file.tags, '$') as jt where atom is not null diff --git a/server/filter/filters.go b/server/filter/filters.go index 5fa53daba..067f7b1f9 100644 --- a/server/filter/filters.go +++ b/server/filter/filters.go @@ -194,6 +194,16 @@ func ByAlbumID(albumIds []string) Sqlizer { return Eq{"album_id": albumIds} } +// AlbumsByYears matches albums whose production year (max_year) is in years. +func AlbumsByYears(years []int) Sqlizer { + return Eq{"max_year": years} +} + +// SongsByYears matches media files whose year is in years. +func SongsByYears(years []int) Sqlizer { + return Eq{"year": years} +} + // ArtistsByGenreID matches artists credited as album artist on an album with any of the given // genre tag ids. Non-correlated semi-join: the correlated EXISTS form rescans albums per artist row. func ArtistsByGenreID(genreIds []string) Sqlizer { @@ -204,10 +214,17 @@ func ArtistsByGenreID(genreIds []string) Sqlizer { ) } -// genreTagFilter builds an EXISTS over the genre entries in the tags JSON, matching each entry -// against cond (its name via Like, or its tag id via Eq/IN). Shared by the name- and id-based lookups. -func genreTagFilter(cond Sqlizer) Sqlizer { - return persistence.Exists(`json_tree(tags, "$.genre")`, And{NotEq{"atom": nil}, cond}) +// tagIDFilter builds an EXISTS over the given tag role's entries in the tags JSON, matching each +// entry against cond (its name via Like, or its tag id via Eq/IN). +func tagIDFilter(tagName string, cond Sqlizer) Sqlizer { + return persistence.Exists(`json_tree(tags, "$.`+tagName+`")`, And{NotEq{"atom": nil}, cond}) +} + +func genreTagFilter(cond Sqlizer) Sqlizer { return tagIDFilter("genre", cond) } + +// ByStudioID matches items (albums or songs) whose record-label tag id is in ids. +func ByStudioID(ids []string) Sqlizer { + return tagIDFilter("recordlabel", Eq{"value": ids}) } func filterByGenre(genre string) Sqlizer { diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 0f79011c7..1f46c08b4 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -146,6 +146,8 @@ func (api *Router) routes() http.Handler { r.Get("/items/{itemId}/instantmix", api.getInstantMix) r.Get("/genres", api.getGenres) r.Get("/musicgenres", api.getGenres) + r.Get("/studios", api.getStudios) + r.Get("/items/filters", api.getQueryFiltersLegacy) r.Post("/playlists", api.createPlaylist) r.Get("/playlists/{playlistId}", api.getPlaylist) diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go index fae21fc0b..29fe48c2b 100644 --- a/server/jellyfin/browsing.go +++ b/server/jellyfin/browsing.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils/slice" ) // getArtists handles GET /Artists (performing artists, Finamp's "Artists" tab); getAlbumArtists @@ -27,7 +28,7 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)} applySort(&opts, "MusicArtist", p.StringOr("sortby", ""), p.StringOr("sortorder", "")) - scopeIDs, _ := resolveLibraryScope(ctx, dto.DecodeID(p.StringOr("parentid", ""))) + scopeIDs, _ := parentIDScope(ctx, r) // Only the fields listArtists reads; /Artists has no favorites filter, so favOnly stays false. // Finamp's artist tab sends GenreIds when a genre filter is active. q := itemsQuery{ @@ -59,3 +60,44 @@ func (api *Router) getGenres(w http.ResponseWriter, r *http.Request) { } api.ok(w, r, res) } + +// getStudios handles GET /Studios, exposing record labels (Jellyfin's audio "studio" source) as +// Studio items, scoped to ParentId's library when accessible. +func (api *Router) getStudios(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + p := req.Params(r) + scope, _ := parentIDScope(ctx, r) + opts := model.QueryOptions{Sort: "tag_value", Filters: libraryScopeFilter(scope)} + labels, err := api.ds.Tag(ctx).GetAll(model.TagRecordLabel, opts) + if err != nil { + api.internalError(w, r, err) + return + } + items := slice.Map(labels, dto.StudioToBaseItem) + offset, max := p.IntOr("startindex", 0), p.IntOr("limit", 0) + api.ok(w, r, result(paginate(items, offset, max), len(items), offset)) +} + +// getQueryFiltersLegacy handles GET /Items/Filters. Genres and Years are scoped to ParentId's +// library when accessible. Tags/OfficialRatings have no music source, so they are always empty. +func (api *Router) getQueryFiltersLegacy(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + scope, _ := parentIDScope(ctx, r) + genreOpts := model.QueryOptions{Sort: "name", Filters: libraryScopeFilter(scope)} + genres, err := api.ds.Genre(ctx).GetAll(genreOpts) + if err != nil { + api.internalError(w, r, err) + return + } + years, err := api.ds.Album(ctx).GetYears(scope...) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, dto.QueryFiltersLegacy{ + Genres: slice.Map(genres, func(g model.Genre) string { return g.Name }), + Tags: []string{}, + OfficialRatings: []string{}, + Years: years, + }) +} diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go index 660e5d293..e346b56cb 100644 --- a/server/jellyfin/browsing_test.go +++ b/server/jellyfin/browsing_test.go @@ -175,4 +175,52 @@ var _ = Describe("Browsing", func() { Expect(w.Code).To(Equal(http.StatusOK)) }) }) + + Describe("getStudios", func() { + It("scopes results to the user's accessible libraries", func() { + tagRepo := ds.Tag(context.Background()).(*tests.MockTagRepo) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Studios", nil).WithContext(ctxUser(model.Libraries{{ID: 1}, {ID: 2}})) + invoke(api.getStudios, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := tagRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_tag.library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + // An empty scope (admin, or a non-admin with no explicit library grants) must be treated + // as unrestricted, matching accessibleLibraryIDs' documented contract, not as "match nothing". + It("does not restrict results for an admin user", func() { + tagRepo := ds.Tag(context.Background()).(*tests.MockTagRepo) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Studios", nil).WithContext(ctxAdmin()) + invoke(api.getStudios, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(tagRepo.Options.Filters).To(BeNil()) + }) + }) + + Describe("getQueryFiltersLegacy", func() { + It("scopes genres to the user's accessible libraries", func() { + genreRepo := ds.Genre(context.Background()).(*tests.MockedGenreRepo) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/Filters", nil).WithContext(ctxUser(model.Libraries{{ID: 1}, {ID: 2}})) + invoke(api.getQueryFiltersLegacy, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := genreRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_tag.library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("does not restrict genres for an admin user", func() { + genreRepo := ds.Genre(context.Background()).(*tests.MockedGenreRepo) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/Filters", nil).WithContext(ctxAdmin()) + invoke(api.getQueryFiltersLegacy, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(genreRepo.Options.Filters).To(BeNil()) + }) + }) }) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index c8f7129cb..9b2c35c69 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -71,6 +71,7 @@ type BaseItemDto struct { ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` Genres []string `json:"Genres,omitempty"` GenreItems []NameGuidPair `json:"GenreItems,omitempty"` + Studios []NameGuidPair `json:"Studios,omitempty"` NormalizationGain *float64 `json:"NormalizationGain,omitempty"` AlbumNormalizationGain *float64 `json:"AlbumNormalizationGain,omitempty"` ChildCount *int `json:"ChildCount,omitempty"` @@ -288,3 +289,12 @@ type LyricLineCue struct { Start int64 `json:"Start"` End *int64 `json:"End,omitempty"` } + +// QueryFiltersLegacy is the response for GET /Items/Filters. All four lists are always present; +// clients (jellyfin-web) render each unconditionally. +type QueryFiltersLegacy struct { + Genres []string `json:"Genres"` + Tags []string `json:"Tags"` + OfficialRatings []string `json:"OfficialRatings"` + Years []int `json:"Years"` +} diff --git a/server/jellyfin/dto/filters_test.go b/server/jellyfin/dto/filters_test.go new file mode 100644 index 000000000..ad3367264 --- /dev/null +++ b/server/jellyfin/dto/filters_test.go @@ -0,0 +1,22 @@ +package dto + +import ( + "encoding/json" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("QueryFiltersLegacy", func() { + It("marshals all four keys, empty ones as [] not null", func() { + b, err := json.Marshal(QueryFiltersLegacy{ + Genres: []string{"Rock"}, Tags: []string{}, OfficialRatings: []string{}, Years: []int{1999}, + }) + Expect(err).ToNot(HaveOccurred()) + j := string(b) + Expect(j).To(ContainSubstring(`"Genres":["Rock"]`)) + Expect(j).To(ContainSubstring(`"Tags":[]`)) + Expect(j).To(ContainSubstring(`"OfficialRatings":[]`)) + Expect(j).To(ContainSubstring(`"Years":[1999]`)) + }) +}) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 036dfbd91..444a25cac 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -200,7 +200,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { return item } -func AlbumToBaseItem(al model.Album) BaseItemDto { +func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { item := BaseItemDto{ Name: al.Name, Id: EncodeID(al.ID), @@ -232,6 +232,14 @@ func AlbumToBaseItem(al model.Album) BaseItemDto { item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name}) } } + // Jellyfin leaves Studios empty for music; we expose record labels here to match our /Studios + // list and StudioIds= filter, so a client can display and click through to filter by label. + if fields.Has("Studios") { + for _, label := range al.Tags.Values(model.TagRecordLabel) { + id := EncodeID(model.NewTag(model.TagRecordLabel, label).ID) + item.Studios = append(item.Studios, NameGuidPair{Name: label, Id: id}) + } + } // The album's own ReplayGain gain (dB at the RG2 -18 LUFS reference) — same // convention as tracks; clients read it off the album item as NormalizationGain. item.NormalizationGain = al.RGAlbumGain @@ -264,6 +272,15 @@ func GenreToBaseItem(g model.Genre) BaseItemDto { } } +func StudioToBaseItem(t model.Tag) BaseItemDto { + return BaseItemDto{ + Name: t.TagValue, + Id: EncodeID(t.ID), + Type: "Studio", + BackdropImageTags: []string{}, + } +} + // PlaylistToBaseItem maps a playlist to a Playlist BaseItemDto. func PlaylistToBaseItem(p model.Playlist) BaseItemDto { // Finamp caches covers keyed by blurHash, so the tag (and blurhash) must change with the cover. diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index c986e7f81..c11f38eaf 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -244,7 +244,7 @@ var _ = Describe("mappers", func() { It("maps an album to a MusicAlbum folder item", func() { al := model.Album{ID: "alb-1", Name: "Alb", AlbumArtist: "AA", AlbumArtistID: "art-1", MaxYear: 1999, SongCount: 10, Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}}} - item := AlbumToBaseItem(al) + item := AlbumToBaseItem(al, nil) Expect(item.Type).To(Equal("MusicAlbum")) Expect(item.IsFolder).To(BeTrue()) Expect(item.Id).To(Equal(EncodeID("alb-1"))) @@ -260,9 +260,22 @@ var _ = Describe("mappers", func() { Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) + It("populates album Studios from record-label tags only when Fields=Studios", func() { + al := model.Album{ID: "alb-2", Name: "Alb2"} + al.Tags = model.Tags{model.TagRecordLabel: []string{"Columbia", "Legacy"}} + + Expect(AlbumToBaseItem(al, nil).Studios).To(BeEmpty()) + + item := AlbumToBaseItem(al, ParseFields("Studios")) + Expect(item.Studios).To(Equal([]NameGuidPair{ + {Name: "Columbia", Id: EncodeID(model.NewTag(model.TagRecordLabel, "Columbia").ID)}, + {Name: "Legacy", Id: EncodeID(model.NewTag(model.TagRecordLabel, "Legacy").ID)}, + })) + }) + It("sets NormalizationGain on the album from its ReplayGain", func() { al := model.Album{ID: "al1", Name: "Album", RGAlbumGain: new(-6.0)} - b, err := json.Marshal(AlbumToBaseItem(al)) + b, err := json.Marshal(AlbumToBaseItem(al, nil)) Expect(err).ToNot(HaveOccurred()) Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-6`)) // Real Jellyfin never sets AlbumNormalizationGain on an album item. @@ -270,7 +283,7 @@ var _ = Describe("mappers", func() { }) It("omits NormalizationGain when the album has no ReplayGain", func() { - b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: "al1", Name: "Album"})) + b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: "al1", Name: "Album"}, nil)) Expect(err).ToNot(HaveOccurred()) Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) }) @@ -293,6 +306,13 @@ var _ = Describe("mappers", func() { Expect(item.Name).To(Equal("Rock")) }) + It("maps a tag to a Studio BaseItemDto", func() { + item := StudioToBaseItem(model.Tag{ID: "t1", TagValue: "Blue Note"}) + Expect(item.Type).To(Equal("Studio")) + Expect(item.Name).To(Equal("Blue Note")) + Expect(item.Id).To(Equal(EncodeID("t1"))) + }) + Describe("premiereDate", func() { // Finamp re-sorts "Latest Releases" client-side by PremiereDate; absent values sort arbitrarily. It("serializes a full date", func() { @@ -321,9 +341,9 @@ var _ = Describe("mappers", func() { }) It("is set on albums from their date, falling back to MaxYear", func() { - Expect(*AlbumToBaseItem(model.Album{ID: "a1", Date: "2013-09-06"}).PremiereDate).To(Equal("2013-09-06T00:00:00Z")) - Expect(*AlbumToBaseItem(model.Album{ID: "a2", MaxYear: 2013}).PremiereDate).To(Equal("2013-01-01T00:00:00Z")) - Expect(AlbumToBaseItem(model.Album{ID: "a3"}).PremiereDate).To(BeNil()) + Expect(*AlbumToBaseItem(model.Album{ID: "a1", Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00Z")) + Expect(*AlbumToBaseItem(model.Album{ID: "a2", MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00Z")) + Expect(AlbumToBaseItem(model.Album{ID: "a3"}, nil).PremiereDate).To(BeNil()) }) }) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index a0632994e..be6d6bb5d 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -2,6 +2,7 @@ package e2e import ( "net/http" + "sort" "time" "github.com/navidrome/navidrome/server/jellyfin/dto" @@ -210,6 +211,44 @@ var _ = Describe("Browsing", func() { }) }) + Describe("year filtering (Years=)", func() { + It("filters items by Years=", func() { + albums := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Years=1959")) + Expect(names(albums.Items)).To(ConsistOf("Kind of Blue")) + + songs := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Years=1959")) + for _, it := range songs.Items { + Expect(it.ProductionYear).ToNot(BeNil()) + Expect(*it.ProductionYear).To(Equal(1959)) + } + Expect(songs.Items).ToNot(BeEmpty()) + }) + }) + + Describe("studio filtering (StudioIds=)", func() { + It("filters items by StudioIds=", func() { + studios := queryResult(get("/Studios")) + var columbiaID string + for _, it := range studios.Items { + if it.Name == "Columbia" { + columbiaID = it.Id + } + } + Expect(columbiaID).ToNot(BeEmpty()) + + albums := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=" + columbiaID)) + Expect(names(albums.Items)).To(ConsistOf("Kind of Blue")) + }) + + It("returns filter lists scoped to a ParentId library", func() { + var filters dto.QueryFiltersLegacy + parseInto(get("/Items/Filters?ParentId="+enc("1")+"&IncludeItemTypes=Audio&Recursive=true"), &filters) + Expect(filters.Years).To(ContainElements(1959, 1965)) + studios := queryResult(get("/Studios?ParentId=" + enc("1"))) + Expect(names(studios.Items)).To(ContainElement("Columbia")) + }) + }) + // Finamp's genre screen sends ParentId= (scoping) plus GenreIds=. Describe("genre filtering (GenreIds)", func() { lib1 := enc("1") @@ -482,5 +521,31 @@ var _ = Describe("Browsing", func() { Expect(q.Items).To(HaveLen(1)) Expect(q.TotalRecordCount).To(Equal(3)) }) + + It("returns record labels as Studio items", func() { + q := queryResult(get("/Studios")) + names := make([]string, 0, len(q.Items)) + for _, it := range q.Items { + Expect(it.Type).To(Equal("Studio")) + names = append(names, it.Name) + } + Expect(names).To(ContainElement("Columbia")) + }) + }) + + Describe("GET /Items/Filters", func() { + It("returns legacy query filters with genres, years, and empty tags/ratings", func() { + var filters dto.QueryFiltersLegacy + parseInto(get("/Items/Filters?IncludeItemTypes=Audio&Recursive=true"), &filters) + Expect(filters.Genres).To(ContainElements("Rock", "Jazz")) + Expect(filters.Years).To(ContainElements(1959, 1965, 1969, 1971)) + // Verify ascending sort by checking it equals itself sorted. + sorted := make([]int, len(filters.Years)) + copy(sorted, filters.Years) + sort.Ints(sorted) + Expect(filters.Years).To(Equal(sorted)) + Expect(filters.Tags).To(BeEmpty()) + Expect(filters.OfficialRatings).To(BeEmpty()) + }) }) }) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 5a4c8c031..31d98d1d9 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -111,7 +111,7 @@ func buildTestFS() storagetest.FakeFS { abbeyRoad := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"}) help := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Help!", "year": 1965, "genre": "Rock"}) ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"}) - kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"}) + kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz", "label": "Columbia"}) singles := template(_t{"albumartist": "Solo Artist", "artist": "Solo Artist", "album": "Singles", "year": 2020, "genre": "Pop"}) return harness.CreateFS(fstest.MapFS{ diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index c3c217cfc..7cf2a17ba 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -222,6 +222,8 @@ type itemsQuery struct { contributingOnly bool genreIds []string albumIds []string + years []int + studioIds []string } // parseItemsQuery also resolves the entity types (inferring them from the parent when @@ -245,7 +247,9 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu // Finamp's genre screen sends ParentId= for scoping plus GenreIds for the genre. genreIds: decodedQueryIDs(r, "genreids"), // Feishin fetches an album's tracks with AlbumIds instead of ParentId. - albumIds: decodedQueryIDs(r, "albumids"), + albumIds: decodedQueryIDs(r, "albumids"), + years: parseYears(r), + studioIds: decodedQueryIDs(r, "studioids"), } // An artist's page filters by artist, not ParentId: Finamp sends ParentId= for scoping // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. @@ -414,6 +418,17 @@ func decodedQueryIDs(r *http.Request, key string) []string { return slice.Map(queryIDs(r, key), dto.DecodeID) } +// parseYears reads Years= as a discrete list, accepting comma-separated and repeated params. +func parseYears(r *http.Request) []int { + var years []int + for _, v := range queryIDs(r, "years") { + if y, err := strconv.Atoi(v); err == nil && y > 0 { + years = append(years, y) + } + } + return years +} + // parseTypes returns the recognized entries in IncludeItemTypes in order, defaulting to // {"MusicAlbum"} when none are recognized (so ParentId= browses that artist's albums). func parseTypes(types string) []string { @@ -481,6 +496,7 @@ func searchPage[S ~[]E, E any](opts model.QueryOptions, search func(model.QueryO } func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { + toItem := func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, q.fields) } repo := api.ds.Album(ctx) filters := squirrel.And{} // For albums, ParentId (browse an artist) and AlbumArtistIds/ArtistIds both mean "this artist's @@ -496,6 +512,12 @@ func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q it if len(q.genreIds) > 0 { filters = append(filters, filter.ByGenreID(q.genreIds)) } + if len(q.years) > 0 { + filters = append(filters, filter.AlbumsByYears(q.years)) + } + if len(q.studioIds) > 0 { + filters = append(filters, filter.ByStudioID(q.studioIds)) + } if q.favOnly { filters = append(filters, filter.ByStarred().Filters) } @@ -509,12 +531,12 @@ func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q it if err != nil { return itemsResult{}, err } - return materialized(result(slice.Map(albums, dto.AlbumToBaseItem), total, opts.Offset)), nil + return materialized(result(slice.Map(albums, toItem), total, opts.Offset)), nil } total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) open := streamCursor(func() (func(func(model.Album, error) bool), error) { return repo.GetCursor(opts) - }, dto.AlbumToBaseItem) + }, toItem) return streamed(open, int(total), opts.Offset), nil } @@ -537,6 +559,12 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite if len(q.genreIds) > 0 { filters = append(filters, filter.ByGenreID(q.genreIds)) } + if len(q.years) > 0 { + filters = append(filters, filter.SongsByYears(q.years)) + } + if len(q.studioIds) > 0 { + filters = append(filters, filter.ByStudioID(q.studioIds)) + } if q.favOnly { filters = append(filters, filter.ByStarred().Filters) } @@ -665,7 +693,7 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi if !u.HasLibraryAccess(al.LibraryID) { return dto.BaseItemDto{}, false } - return dto.AlbumToBaseItem(*al), true + return dto.AlbumToBaseItem(*al, fields), true } if ar, err := api.ds.Artist(ctx).Get(id); err == nil { // TODO: an artist spans multiple libraries (library_artist), so there's no single @@ -754,13 +782,15 @@ func (api *Router) deleteItem(w http.ResponseWriter, r *http.Request) { // /Items/Latest, and why it writes directly instead of going through api.ok. func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + p := req.Params(r) + fields := dto.ParseFields(p.Strings("fields")...) opts := filter.AlbumsByNewest() - opts.Max = req.Params(r).IntOr("limit", 20) + opts.Max = p.IntOr("limit", 20) opts = filter.ApplyLibraryFilter(opts, accessibleLibraryIDs(ctx)) repo := api.ds.Album(ctx) open := streamCursor(func() (func(func(model.Album, error) bool), error) { return repo.GetCursor(opts) - }, dto.AlbumToBaseItem) + }, func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, fields) }) api.writeItemsArray(w, r, streamed(open, 0, 0)) } diff --git a/server/jellyfin/library.go b/server/jellyfin/library.go index 2e486c36f..2830f1105 100644 --- a/server/jellyfin/library.go +++ b/server/jellyfin/library.go @@ -2,11 +2,14 @@ package jellyfin import ( "context" + "net/http" "strconv" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" ) // accessibleLibraryIDs returns the ids of the libraries the current user can access. An empty @@ -30,6 +33,20 @@ func resolveLibraryScope(ctx context.Context, parentId string) (scopeIDs []int, return accessibleLibraryIDs(ctx), false } +// parentIDScope resolves the request's ParentId param to a library scope (see resolveLibraryScope). +func parentIDScope(ctx context.Context, r *http.Request) (scopeIDs []int, isLibraryParent bool) { + return resolveLibraryScope(ctx, dto.DecodeID(req.Params(r).StringOr("parentid", ""))) +} + +// libraryScopeFilter restricts a tag query to the given library scope. Empty scope means +// unrestricted (see accessibleLibraryIDs), so it returns nil rather than an impossible IN (). +func libraryScopeFilter(scope []int) squirrel.Sqlizer { + if len(scope) == 0 { + return nil + } + return squirrel.Eq{"library_tag.library_id": scope} +} + // libraryView builds the CollectionFolder BaseItemDto representing a library as a top-level node. // Shared by getUserViews and getItem, since Finamp fetches a UserView's id as a plain item. func libraryView(lib model.Library) dto.BaseItemDto { diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index 085080850..661fb95cc 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -177,7 +177,7 @@ func (api *Router) similarAlbums(ctx context.Context, id string, limit int) dto. } seen[s.AlbumID] = true if al, err := api.ds.Album(ctx).Get(s.AlbumID); err == nil && u.HasLibraryAccess(al.LibraryID) { - items = append(items, dto.AlbumToBaseItem(*al)) + items = append(items, dto.AlbumToBaseItem(*al, nil)) if len(items) >= limit { break } diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 03dfed879..cc7d66b4d 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -209,4 +209,11 @@ func (m *MockAlbumRepo) SetStar(starred bool, itemIDs ...string) error { return nil } +func (m *MockAlbumRepo) GetYears(libraryIDs ...int) ([]int, error) { + if m.Err { + return nil, errors.New("error") + } + return []int{}, nil +} + var _ model.AlbumRepository = (*MockAlbumRepo)(nil) diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 754f0c084..e016a28de 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -65,7 +65,7 @@ func (db *MockDataStore) Tag(ctx context.Context) model.TagRepository { if db.RealDS != nil { return db.RealDS.Tag(ctx) } - db.MockedTag = struct{ model.TagRepository }{} + db.MockedTag = &MockTagRepo{} return db.MockedTag } diff --git a/tests/mock_genre_repo.go b/tests/mock_genre_repo.go index 122ccc278..50796efc0 100644 --- a/tests/mock_genre_repo.go +++ b/tests/mock_genre_repo.go @@ -5,8 +5,9 @@ import ( ) type MockedGenreRepo struct { - Error error - Data map[string]model.Genre + Error error + Data map[string]model.Genre + Options model.QueryOptions } func (r *MockedGenreRepo) init() { @@ -15,7 +16,10 @@ func (r *MockedGenreRepo) init() { } } -func (r *MockedGenreRepo) GetAll(...model.QueryOptions) (model.Genres, error) { +func (r *MockedGenreRepo) GetAll(options ...model.QueryOptions) (model.Genres, error) { + if len(options) > 0 { + r.Options = options[0] + } if r.Error != nil { return nil, r.Error } diff --git a/tests/mock_tag_repo.go b/tests/mock_tag_repo.go new file mode 100644 index 000000000..a59035ea6 --- /dev/null +++ b/tests/mock_tag_repo.go @@ -0,0 +1,24 @@ +package tests + +import ( + "github.com/navidrome/navidrome/model" +) + +// MockTagRepo records the QueryOptions passed to GetAll, mirroring MockArtistRepo, so tests can +// assert on which filters a caller attached (e.g. a library scope). +type MockTagRepo struct { + model.TagRepository + Data model.TagList + Options model.QueryOptions + Err error +} + +func (r *MockTagRepo) GetAll(_ model.TagName, options ...model.QueryOptions) (model.TagList, error) { + if len(options) > 0 { + r.Options = options[0] + } + if r.Err != nil { + return nil, r.Err + } + return r.Data, nil +} From e6597398c2f9834f3376ee041b135d26bfab27e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 19 Jul 2026 13:36:05 -0400 Subject: [PATCH 13/19] feat(scrobbler): exponential backoff for scrobble retries during outages (#5818) * feat(scrobbler): add exponential backoff delay helper * feat(scrobbler): back off retries up to 4m during outages * test(scrobbler): verify backoff schedule with synctest; clarify backoffDelay doc Adds a testing/synctest-based test that drives the real run loop against a failing service and asserts the exact 5s/10s/20s/40s retry schedule and the drain-on-recovery reset, addressing the review note that the run loop's behavior was untested. Also clarifies the backoffDelay doc comment: the argument is a zero-based retry index. --- core/scrobbler/buffered_scrobbler.go | 42 +++++++++-- core/scrobbler/buffered_scrobbler_test.go | 91 +++++++++++++++++++++++ 2 files changed, 128 insertions(+), 5 deletions(-) diff --git a/core/scrobbler/buffered_scrobbler.go b/core/scrobbler/buffered_scrobbler.go index 408ab410d..38ea83228 100644 --- a/core/scrobbler/buffered_scrobbler.go +++ b/core/scrobbler/buffered_scrobbler.go @@ -10,6 +10,30 @@ import ( "github.com/navidrome/navidrome/model/request" ) +const ( + minRetryDelay = 5 * time.Second + maxRetryDelay = 4 * time.Minute + // maxRetryShift caps the exponent so the shift never overflows int64. + // minRetryDelay<<6 = 320s already exceeds maxRetryDelay, so 6 reaches the ceiling. + maxRetryShift = 6 +) + +// backoffDelay returns the delay for a zero-based retry index (0 = first retry): +// minRetryDelay doubled per prior failure, clamped to maxRetryDelay. +func backoffDelay(failures int) time.Duration { + if failures < 0 { + failures = 0 + } + if failures >= maxRetryShift { + return maxRetryDelay + } + d := minRetryDelay << failures + if d > maxRetryDelay { + return maxRetryDelay + } + return d +} + // Loader is a function that loads a scrobbler by name. // It returns the scrobbler and true if found, or nil and false if not available. // This allows the buffered scrobbler to always get the current plugin instance. @@ -98,15 +122,23 @@ func (b *bufferedScrobbler) sendWakeSignal() { } func (b *bufferedScrobbler) run(ctx context.Context) { + timer := time.NewTimer(time.Hour) + timer.Stop() + defer timer.Stop() + failures := 0 for { - if !b.processQueue(ctx) { - time.AfterFunc(5*time.Second, func() { - b.sendWakeSignal() - }) + if b.processQueue(ctx) { + failures = 0 + timer.Stop() + } else { + timer.Reset(backoffDelay(failures)) + if failures < maxRetryShift { + failures++ + } } select { case <-b.wakeSignal: - continue + case <-timer.C: case <-ctx.Done(): return } diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go index d11f0b003..c250085ef 100644 --- a/core/scrobbler/buffered_scrobbler_test.go +++ b/core/scrobbler/buffered_scrobbler_test.go @@ -2,6 +2,9 @@ package scrobbler import ( "context" + "sync/atomic" + "testing" + "testing/synctest" "time" "github.com/navidrome/navidrome/model" @@ -100,3 +103,91 @@ var _ = Describe("BufferedScrobbler", func() { } }) }) + +var _ = Describe("backoffDelay", func() { + DescribeTable("computes the exponential backoff curve clamped to the ceiling", + func(failures int, expected time.Duration) { + Expect(backoffDelay(failures)).To(Equal(expected)) + }, + Entry("first failure", 0, 5*time.Second), + Entry("second failure", 1, 10*time.Second), + Entry("third failure", 2, 20*time.Second), + Entry("fourth failure", 3, 40*time.Second), + Entry("fifth failure", 4, 80*time.Second), + Entry("sixth failure", 5, 160*time.Second), + Entry("reaches the ceiling", 6, 4*time.Minute), + Entry("stays clamped past the ceiling", 7, 4*time.Minute), + Entry("stays clamped for large values", 1000, 4*time.Minute), + Entry("negative is treated as zero", -1, 5*time.Second), + ) +}) + +// Drives the real run loop and asserts the exact retry schedule + recovery. Plain +// test: testing/synctest's fake clock needs a *testing.T, which Ginkgo doesn't give. +func TestBufferedScrobblerBackoffSchedule(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + buffer := tests.CreateMockedScrobbleBufferRepo() + userRepo := tests.CreateMockUserRepo() + g.Expect(userRepo.Put(&model.User{ID: "user1", UserName: "alice"})).To(Succeed()) + ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo} + + flaky := &recoveringScrobbler{} + flaky.fail(ErrRetryLater) + bs := newBufferedScrobbler(ds, flaky, "flaky") + defer func() { bs.Stop(); synctest.Wait() }() + + // Let the loop settle on the empty buffer, then enqueue a scrobble. + synctest.Wait() + track := model.MediaFile{ID: "123", Title: "Test Track", Artist: "Test Artist"} + g.Expect(bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: track, TimeStamp: time.Now()})).To(Succeed()) + + // First attempt fires immediately on the enqueue wake and is left buffered. + synctest.Wait() + g.Expect(flaky.count.Load()).To(Equal(int32(1))) + g.Expect(buffer.Length()).To(Equal(int64(1))) + + // Each subsequent retry waits exactly double the previous: 5s, 10s, 20s, 40s. + for i, gap := range []time.Duration{5 * time.Second, 10 * time.Second, 20 * time.Second, 40 * time.Second} { + want := int32(i + 2) + time.Sleep(gap - time.Nanosecond) + synctest.Wait() + g.Expect(flaky.count.Load()).To(Equal(want-1), "retry fired before the %s backoff", gap) + time.Sleep(time.Nanosecond) + synctest.Wait() + g.Expect(flaky.count.Load()).To(Equal(want), "retry did not fire after the %s backoff", gap) + } + + // Once the service recovers, waking the loop drains the buffered entry. + flaky.succeed() + bs.sendWakeSignal() + synctest.Wait() + g.Expect(buffer.Length()).To(Equal(int64(0))) + }) +} + +// recoveringScrobbler is a race-safe Scrobbler whose error can be toggled while +// the buffered scrobbler's goroutine is draining, to exercise retry then recovery. +type recoveringScrobbler struct { + err atomic.Pointer[error] + count atomic.Int32 +} + +func (f *recoveringScrobbler) fail(err error) { f.err.Store(&err) } +func (f *recoveringScrobbler) succeed() { f.err.Store(nil) } + +func (f *recoveringScrobbler) IsAuthorized(context.Context, string) bool { return true } + +func (f *recoveringScrobbler) NowPlaying(context.Context, string, *model.MediaFile, int) error { + return nil +} + +func (f *recoveringScrobbler) Scrobble(_ context.Context, _ string, _ Scrobble) error { + f.count.Add(1) + if e := f.err.Load(); e != nil { + return *e + } + return nil +} + +func (f *recoveringScrobbler) PlaybackReport(context.Context, PlaybackSession) error { return nil } From fed966506012113ba7e4272ba422116c43d28b06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 19 Jul 2026 18:51:32 -0400 Subject: [PATCH 14/19] fix(streaming): surface why a transcode decision failed (#5820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(subsonic): surface the reason a transcode decision failed getTranscodeDecision returned a bare "failed to make transcode decision" with no clue why, and the probe command ran ffprobe with -v quiet, so even the server log bottomed out at "exit status 1". A user whose files had been moved by an external tool only saw the opaque error. ProbeAudioStream now returns a typed ProbeError that separates the file path from the reason: ffprobe runs with -v error so its stderr diagnostic is captured, and a missing or unreadable file is reported as "file not found" rather than ffprobe's misleading "Invalid data found". The handler logs the full detail (including the path) and returns the reason to the client with the server path stripped out. Reported-by: Tolriq (Symfonium) * refactor(ffmpeg): use errors.AsType for ExitError match probeErrorReason used the older var+errors.As form while the rest of the codebase (and its sibling transcodeFailureReason) uses the generic errors.AsType. Switch to it for consistency; behavior is unchanged. * fix(subsonic): return error 70 when the source file is missing A getTranscodeDecision probe failure was always reported as generic error 0. When the source file is gone (moved or deleted out from under the DB), that is a not-found condition, so return the standard Subsonic error 70 ("data not found") instead — matching what the endpoint already returns for an unknown mediaId. Files that exist but are corrupt or unreadable stay error 0. ProbeError now wraps the underlying cause and implements Unwrap, so the handler detects the case with errors.Is(err, fs.ErrNotExist). * fix(ffmpeg): keep probe error paths out of client-facing reasons Addresses review feedback on the ProbeError type: the Reason field doubled as both the log detail and the client message, so an ffprobe launch failure (a *os.PathError from fork/exec) could leak the ffprobe binary path to clients, and an unexpected stat error was reduced to "file not accessible" in the log. Split the two concerns: Reason now holds only a path-free, client-safe string (built at construction), while Error() logs the full underlying cause. Launch failures return a generic "could not read file" instead of the raw exec error. SafeReason no longer does substring path-stripping (removing the empty-Path edge case); the stripping happens once, against ffprobe's stderr. * fix(subsonic): don't report a broken ffprobe as a missing media file Two issues from review of the previous commit: Code 70 was selected with errors.Is(err, fs.ErrNotExist), but a launch failure of a deleted ffprobe binary is an *os.PathError that also wraps fs.ErrNotExist. A server-side ffprobe problem was therefore reported to clients as a missing media file. ProbeError now carries an explicit NotFound flag, set only on the file-access branch, and the handler keys the code off that instead of the chain. ffprobe can also exit 0 while yielding no audio stream (an audio-suffixed container holding only video). That parse failure was returned unwrapped, so clients got "internal error"; it is now wrapped in a ProbeError too. --- core/ffmpeg/ffmpeg.go | 73 +++++++++++++++++++++++++++++-- core/ffmpeg/ffmpeg_test.go | 70 +++++++++++++++++++++++++++++ server/subsonic/transcode.go | 17 ++++++- server/subsonic/transcode_test.go | 49 +++++++++++++++++++++ 4 files changed, 204 insertions(+), 5 deletions(-) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 3d4cd0e72..af2dab647 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "io/fs" "os" "os/exec" "path/filepath" @@ -67,7 +68,7 @@ var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder const ( extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -" probeCmd = "ffmpeg %s -f ffmetadata" - probeAudioStreamCmd = "ffprobe -v quiet -select_streams a:0 -print_format json -show_streams -show_format %s" + probeAudioStreamCmd = "ffprobe -v error -select_streams a:0 -print_format json -show_streams -show_format %s" ) type ffmpeg struct{} @@ -159,16 +160,80 @@ func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioP return nil, err } if err := fileExists(filePath); err != nil { - return nil, err + return nil, &ProbeError{Path: filePath, Reason: fileAccessReason(err), + NotFound: errors.Is(err, fs.ErrNotExist), err: err} } args := createFFmpegCommand(probeAudioStreamCmd, filePath, 0, 0) log.Trace(ctx, "Executing ffprobe command", "args", args) cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec output, err := cmd.Output() if err != nil { - return nil, fmt.Errorf("running ffprobe on %q: %w", filePath, err) + return nil, &ProbeError{Path: filePath, Reason: probeClientReason(err, filePath), err: err} } - return parseProbeOutput(output) + result, err := parseProbeOutput(output) + if err != nil { + return nil, &ProbeError{Path: filePath, Reason: err.Error(), err: err} + } + return result, nil +} + +// ProbeError reports an ffprobe failure. Reason is a path-free message safe to +// expose to clients; the wrapped cause carries the full detail for logging. +// NotFound marks the media file itself as missing — a launch failure of a +// deleted ffprobe binary also wraps fs.ErrNotExist, so callers must not infer +// it from the error chain. +type ProbeError struct { + Path string + Reason string + NotFound bool + err error +} + +func (e *ProbeError) Error() string { + if e.err == nil { + return fmt.Sprintf("probe failed on %q: %s", e.Path, e.Reason) + } + return fmt.Sprintf("probe failed on %q: %s", e.Path, probeDetail(e.err)) +} + +// Unwrap exposes the underlying cause so callers can test it with errors.Is +// (e.g. fs.ErrNotExist to detect a missing file). +func (e *ProbeError) Unwrap() error { return e.err } + +// SafeReason returns the path-free reason, safe to send to clients. +func (e *ProbeError) SafeReason() string { return e.Reason } + +// fileAccessReason maps a stat failure to a clear, path-free reason, so a moved +// or unreadable file reads as "file not found" rather than a raw ffprobe message. +func fileAccessReason(err error) string { + switch { + case errors.Is(err, fs.ErrNotExist): + return "file not found" + case errors.Is(err, fs.ErrPermission): + return "permission denied" + default: + return "file not accessible" + } +} + +// probeDetail returns the full diagnostic for logging (may contain paths): +// ffprobe's stderr when present, otherwise the raw error text. +func probeDetail(err error) string { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && len(exitErr.Stderr) > 0 { + return strings.TrimSpace(string(exitErr.Stderr)) + } + return err.Error() +} + +// probeClientReason returns a path-free reason for an ffprobe execution failure: +// ffprobe's stderr with the file path stripped, or a generic reason when ffprobe +// couldn't run at all (its launch error may embed the binary path). +func probeClientReason(err error, path string) string { + exitErr, ok := errors.AsType[*exec.ExitError](err) + if !ok || len(exitErr.Stderr) == 0 { + return "could not read file" + } + return strings.TrimSpace(strings.ReplaceAll(string(exitErr.Stderr), path, "the file")) } type probeOutput struct { diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 9c20e6c05..0fa3de111 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -2,6 +2,7 @@ package ffmpeg import ( "context" + "errors" "os" "os/exec" "path/filepath" @@ -553,6 +554,65 @@ var _ = Describe("ffmpeg", func() { }) }) + Describe("ProbeError", func() { + It("uses the underlying cause in Error() so logs keep the full detail", func() { + e := &ProbeError{Path: "/music/foo.flac", + err: errors.New("/music/foo.flac: Invalid data found when processing input")} + Expect(e.Error()).To(ContainSubstring("/music/foo.flac")) + Expect(e.Error()).To(ContainSubstring("Invalid data found when processing input")) + }) + + It("returns the path-free reason from SafeReason()", func() { + e := &ProbeError{Path: "/music/foo.flac", Reason: "the file: Invalid data found when processing input"} + Expect(e.SafeReason()).To(Equal("the file: Invalid data found when processing input")) + Expect(e.SafeReason()).ToNot(ContainSubstring("/music/foo.flac")) + }) + + It("unwraps to the underlying cause so errors.Is detects a missing file", func() { + e := &ProbeError{Path: "/music/foo.flac", Reason: "file not found", err: os.ErrNotExist} + Expect(errors.Is(e, os.ErrNotExist)).To(BeTrue()) + }) + }) + + Describe("probeClientReason", func() { + It("strips the file path from ffprobe stderr", func() { + if runtime.GOOS == "windows" { + Skip("uses /bin/sh") + } + _, err := exec.Command("/bin/sh", "-c", "echo '/music/foo.flac: Invalid data found' >&2; exit 1").Output() + Expect(err).To(HaveOccurred()) + Expect(probeClientReason(err, "/music/foo.flac")).To(Equal("the file: Invalid data found")) + }) + + It("returns a generic reason for launch failures, without leaking the binary path", func() { + err := errors.New("fork/exec /opt/navidrome/bin/ffprobe: no such file or directory") + Expect(probeClientReason(err, "/music/foo.flac")).To(Equal("could not read file")) + }) + }) + + Describe("probeDetail", func() { + It("surfaces ffprobe stderr for logging", func() { + if runtime.GOOS == "windows" { + Skip("uses /bin/sh") + } + _, err := exec.Command("/bin/sh", "-c", "echo 'boom detail' >&2; exit 1").Output() + Expect(err).To(HaveOccurred()) + Expect(probeDetail(err)).To(Equal("boom detail")) + }) + }) + + Describe("fileAccessReason", func() { + It("reports a missing file as 'file not found', not a raw stat message", func() { + _, err := os.Stat("/no/such/dir/really-missing.flac") + Expect(err).To(HaveOccurred()) + Expect(fileAccessReason(err)).To(Equal("file not found")) + }) + + It("falls back to a generic reason for other access errors", func() { + Expect(fileAccessReason(errors.New("boom"))).To(Equal("file not accessible")) + }) + }) + Describe("FFmpeg", func() { Context("when FFmpeg is available", func() { var ff FFmpeg @@ -566,6 +626,16 @@ var _ = Describe("ffmpeg", func() { } }) + It("ProbeAudioStream returns a not-found ProbeError for a missing file", func() { + _, err := ff.ProbeAudioStream(GinkgoT().Context(), "/no/such/dir/really-missing.flac") + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, os.ErrNotExist)).To(BeTrue()) + var pe *ProbeError + Expect(errors.As(err, &pe)).To(BeTrue()) + Expect(pe.SafeReason()).To(Equal("file not found")) + Expect(pe.NotFound).To(BeTrue()) + }) + It("should interrupt transcoding when context is cancelled", func() { ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second) defer cancel() diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 511db2b85..9eb2af160 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -8,6 +8,7 @@ import ( "slices" "strconv" + "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -315,7 +316,8 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo, stream.TranscodeOptions{}) if err != nil { log.Error(ctx, "Failed to make transcode decision", "mediaID", mediaID, err) - return nil, newError(responses.ErrorGeneric, "failed to make transcode decision") + code, reason := transcodeFailure(err) + return nil, newError(code, "failed to make transcode decision: %s", reason) } // Only create a token when there is a valid playback path @@ -346,6 +348,19 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) return response, nil } +// transcodeFailure maps a decision error to a Subsonic error code and a reason +// safe to send to clients, omitting server file paths. +func transcodeFailure(err error) (int32, string) { + pe, ok := errors.AsType[*ffmpeg.ProbeError](err) + if !ok { + return responses.ErrorGeneric, "internal error" + } + if pe.NotFound { + return responses.ErrorDataNotFound, pe.SafeReason() + } + return responses.ErrorGeneric, pe.SafeReason() +} + // GetTranscodeStream handles the OpenSubsonic getTranscodeStream endpoint. // It streams media using the decision encoded in the transcodeParams JWT token. // All errors are returned as proper HTTP status codes (not Subsonic error responses). diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 7e36ab243..8d5cbb974 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -4,12 +4,16 @@ import ( "bytes" "context" "errors" + "fmt" + "io/fs" "net/http" "net/http/httptest" + "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -77,6 +81,47 @@ var _ = Describe("Transcode endpoints", func() { Expect(err.Error()).To(ContainSubstring("error retrieving media file")) }) + It("enriches the decision error with the reason, without leaking the file path", func() { + mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}}) + mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w", + &ffmpeg.ProbeError{Path: "/music/secret/foo.flac", Reason: "the file: Invalid data found when processing input"}) + r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to make transcode decision")) + Expect(err.Error()).To(ContainSubstring("Invalid data found when processing input")) + Expect(err.Error()).ToNot(ContainSubstring("/music/secret")) + var subErr subError + Expect(errors.As(err, &subErr)).To(BeTrue()) + Expect(subErr.code).To(Equal(responses.ErrorGeneric)) + }) + + It("returns ErrorDataNotFound when the source file is missing on disk", func() { + mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}}) + mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w", + &ffmpeg.ProbeError{Path: "/music/gone.flac", Reason: "file not found", NotFound: true}) + r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("file not found")) + var subErr subError + Expect(errors.As(err, &subErr)).To(BeTrue()) + Expect(subErr.code).To(Equal(responses.ErrorDataNotFound)) + }) + + It("keeps ErrorGeneric when ffprobe is missing, even though the cause wraps fs.ErrNotExist", func() { + mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}}) + pe := &ffmpeg.ProbeError{Path: "/music/song.flac", Reason: "could not read file"} + mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w (%w)", pe, fs.ErrNotExist) + Expect(errors.Is(mockTD.decisionErr, fs.ErrNotExist)).To(BeTrue()) + r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + var subErr subError + Expect(errors.As(err, &subErr)).To(BeTrue()) + Expect(subErr.code).To(Equal(responses.ErrorGeneric)) + }) + It("returns error when body is empty", func() { r := newJSONPostRequest("mediaId=song-1&mediaType=song", "") _, err := router.GetTranscodeDecision(w, r) @@ -516,6 +561,7 @@ func newJSONPostRequest(queryParams string, jsonBody string) *http.Request { // mockTranscodeDecision is a test double for stream.TranscodeDecider type mockTranscodeDecision struct { decision *stream.TranscodeDecision + decisionErr error token string tokenErr error resolvedReq stream.Request @@ -525,6 +571,9 @@ type mockTranscodeDecision struct { func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, ci *stream.ClientInfo, _ stream.TranscodeOptions) (*stream.TranscodeDecision, error) { m.capturedClient = ci + if m.decisionErr != nil { + return nil, m.decisionErr + } if m.decision != nil { return m.decision, nil } From bf79d2f3a2a8c569d4d533a596f4575d39d5e1dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 19 Jul 2026 18:52:30 -0400 Subject: [PATCH 15/19] refactor(plugins): remove non-functional experimental manifest option (#5821) The `experimental.threads` manifest option never actually worked, so drop it from the schema, the generated types, the loader and the docs. --- plugins/README.md | 20 +---------- plugins/manager_loader.go | 8 ----- plugins/manifest-schema.json | 24 ------------- plugins/manifest.go | 5 --- plugins/manifest_gen.go | 15 -------- plugins/manifest_test.go | 70 ------------------------------------ 6 files changed, 1 insertion(+), 141 deletions(-) diff --git a/plugins/README.md b/plugins/README.md index b9118d36f..08ab967f3 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -136,7 +136,7 @@ Every plugin must include a `manifest.json` file. Example: **Required fields:** `name`, `author`, `version` -**Optional fields:** `description`, `website`, `config`, `permissions`, `experimental` +**Optional fields:** `description`, `website`, `config`, `permissions` #### Config Definition @@ -160,24 +160,6 @@ The `config` field defines the plugin's configuration schema using [JSON Schema } ``` -#### Experimental Features - -Plugins can opt-in to experimental WebAssembly features that may change or be removed in future versions. Currently supported: - -- **`threads`** – Enables WebAssembly threads support (for plugins compiled with multi-threading) - -```json -{ - "experimental": { - "threads": { - "reason": "Required for concurrent audio processing" - } - } -} -``` - -> **Note:** Experimental features may have compatibility or performance implications. Use only when necessary. - --- ## Capabilities diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 675c85e26..0ba5fcf76 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -13,8 +13,6 @@ import ( "github.com/navidrome/navidrome/plugins/host" "github.com/navidrome/navidrome/scheduler" "github.com/tetratelabs/wazero" - "github.com/tetratelabs/wazero/api" - "github.com/tetratelabs/wazero/experimental" "golang.org/x/sync/errgroup" ) @@ -377,12 +375,6 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { WithCompilationCache(m.cache). WithCloseOnContextDone(true) - // Enable experimental threads if requested in manifest - if pkg.Manifest.HasExperimentalThreads() { - runtimeConfig = runtimeConfig.WithCoreFeatures(api.CoreFeaturesV2 | experimental.CoreFeaturesThreads) - log.Debug(ctx, "Enabling experimental threads support") - } - extismConfig := extism.PluginConfig{ EnableWasi: true, RuntimeConfig: runtimeConfig, diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index 29e5d1fc7..28adeed79 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -34,9 +34,6 @@ "permissions": { "$ref": "#/$defs/Permissions" }, - "experimental": { - "$ref": "#/$defs/Experimental" - }, "config": { "$ref": "#/$defs/ConfigDefinition" } @@ -58,27 +55,6 @@ } } }, - "Experimental": { - "type": "object", - "description": "Experimental features that may change or be removed in future versions", - "additionalProperties": false, - "properties": { - "threads": { - "$ref": "#/$defs/ThreadsFeature" - } - } - }, - "ThreadsFeature": { - "type": "object", - "description": "Enable experimental WebAssembly threads support", - "additionalProperties": false, - "properties": { - "reason": { - "type": "string", - "description": "Explanation for why threads support is needed" - } - } - }, "Permissions": { "type": "object", "description": "Permissions required by the plugin", diff --git a/plugins/manifest.go b/plugins/manifest.go index 6bd0e8049..5e144b5c8 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -117,11 +117,6 @@ func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error { return nil } -// HasExperimentalThreads returns true if the manifest requests experimental threads support. -func (m *Manifest) HasExperimentalThreads() bool { - return m.Experimental != nil && m.Experimental.Threads != nil -} - // HasLibraryFilesystemPermission checks if the manifest grants filesystem permission for libraries. func (m *Manifest) HasLibraryFilesystemPermission() bool { return m.Permissions != nil && diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index 3599eafc4..c2aa5e298 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -45,12 +45,6 @@ func (j *ConfigDefinition) UnmarshalJSON(value []byte) error { return nil } -// Experimental features that may change or be removed in future versions -type Experimental struct { - // Threads corresponds to the JSON schema field "threads". - Threads *ThreadsFeature `json:"threads,omitempty" yaml:"threads,omitempty" mapstructure:"threads,omitempty"` -} - // HTTP access permissions for a plugin type HTTPPermission struct { // Explanation for why HTTP access is needed @@ -109,9 +103,6 @@ type Manifest struct { // A brief description of what the plugin does Description *string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description,omitempty"` - // Experimental corresponds to the JSON schema field "experimental". - Experimental *Experimental `json:"experimental,omitempty" yaml:"experimental,omitempty" mapstructure:"experimental,omitempty"` - // The display name of the plugin Name string `json:"name" yaml:"name" mapstructure:"name"` @@ -242,12 +233,6 @@ func (j *TaskQueuePermission) UnmarshalJSON(value []byte) error { return nil } -// Enable experimental WebAssembly threads support -type ThreadsFeature struct { - // Explanation for why threads support is needed - Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` -} - // Users service permissions for accessing user information type UsersPermission struct { // Explanation for why users access is needed diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go index 2a8b0dcfa..32bcbba08 100644 --- a/plugins/manifest_test.go +++ b/plugins/manifest_test.go @@ -117,76 +117,6 @@ var _ = Describe("Manifest", func() { }) }) - Describe("HasExperimentalThreads", func() { - It("returns false when no experimental section", func() { - m := &Manifest{} - Expect(m.HasExperimentalThreads()).To(BeFalse()) - }) - - It("returns false when experimental section has no threads", func() { - m := &Manifest{ - Experimental: &Experimental{}, - } - Expect(m.HasExperimentalThreads()).To(BeFalse()) - }) - - It("returns true when threads feature is present", func() { - m := &Manifest{ - Experimental: &Experimental{ - Threads: &ThreadsFeature{}, - }, - } - Expect(m.HasExperimentalThreads()).To(BeTrue()) - }) - - It("returns true when threads feature has a reason", func() { - m := &Manifest{ - Experimental: &Experimental{ - Threads: &ThreadsFeature{ - Reason: new("Required for concurrent processing"), - }, - }, - } - Expect(m.HasExperimentalThreads()).To(BeTrue()) - }) - - It("parses experimental.threads from JSON", func() { - data := []byte(`{ - "name": "Threaded Plugin", - "author": "Test Author", - "version": "1.0.0", - "experimental": { - "threads": { - "reason": "To use multi-threaded WASM module" - } - } - }`) - - var m Manifest - err := json.Unmarshal(data, &m) - Expect(err).ToNot(HaveOccurred()) - Expect(m.HasExperimentalThreads()).To(BeTrue()) - Expect(m.Experimental.Threads.Reason).ToNot(BeNil()) - Expect(*m.Experimental.Threads.Reason).To(Equal("To use multi-threaded WASM module")) - }) - - It("parses experimental.threads without reason from JSON", func() { - data := []byte(`{ - "name": "Threaded Plugin", - "author": "Test Author", - "version": "1.0.0", - "experimental": { - "threads": {} - } - }`) - - var m Manifest - err := json.Unmarshal(data, &m) - Expect(err).ToNot(HaveOccurred()) - Expect(m.HasExperimentalThreads()).To(BeTrue()) - }) - }) - Describe("ParseManifest", func() { It("parses a valid manifest with users permission", func() { data := []byte(`{ From 6c2644d20860fa052fed1e150b2390727e37fa34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 19 Jul 2026 18:59:37 -0400 Subject: [PATCH 16/19] feat(ui): remember 'items per page' selection across sessions (#5819) * feat(ui): add perPageStore helper for items-per-page persistence * feat(ui): persist items-per-page selection in localStorage * feat(ui): enable per-page persistence on album grid, missing files and playlist tracks * feat(ui): restore saved album grid page size on fresh load * feat(ui): restore saved items-per-page on list load * fix(ui): move defaultRowsPerPageOptions to perPageStore to satisfy react-refresh lint * fix(ui): correct defaultRowsPerPageOptions import in List and add render test * refactor(ui): default getStoredPerPage fallback to the first option The fallback equalled options[0] at three of four call sites; default it so those sites stop restating it. SongList and useAlbumsPerPage still pass an explicit fallback where it legitimately differs from the first option. * fix(ui): persist only user-selected page sizes, validate album size against width Persisting on every pagination context value let a URL-injected perPage (e.g. the perPage=15 album link in NowPlayingPanel) or a forced single-option mobile grid overwrite the saved preference. Persist only a value that is an actual option in a multi-option selector. Also validate the album grid's redux session value against the current width so a size chosen at a wider breakpoint can't leave an out-of-range selector. * fix(ui): restore saved items-per-page on the radio list RadioList passed a hard-coded perPage that overrode List's stored seed via the props spread, so a radio-list page size was persisted but never restored. Seed it from storage like the other list views. * fix(ui): persist page size only on an actual selector change Watching the pagination context value meant any valid value persisted itself: loading a list at a breakpoint where the saved size is invalid stored the responsive fallback, and opening a URL with a valid ?perPage= stored that too, either way discarding the user's real preference. Inject a wrapped setPerPage instead, so only the rows-per-page selector writes. This also drops the option-validation heuristics, which the new trigger makes unnecessary. --- ui/src/album/AlbumList.jsx | 2 +- ui/src/artist/ArtistShow.jsx | 1 + ui/src/common/List.jsx | 3 +- ui/src/common/List.test.jsx | 27 +++++++++++ ui/src/common/Pagination.jsx | 33 +++++++++++-- ui/src/common/Pagination.test.jsx | 62 +++++++++++++++++++++++++ ui/src/common/index.js | 1 + ui/src/common/perPageStore.js | 11 +++++ ui/src/common/perPageStore.test.js | 40 ++++++++++++++++ ui/src/common/useAlbumsPerPage.jsx | 16 +++++-- ui/src/common/useAlbumsPerPage.test.jsx | 61 ++++++++++++++++++++++++ ui/src/missing/MissingFilesList.jsx | 15 ++++-- ui/src/playlist/PlaylistShow.jsx | 20 ++++++-- ui/src/radio/RadioList.jsx | 8 +++- ui/src/song/SongList.jsx | 8 +++- 15 files changed, 286 insertions(+), 22 deletions(-) create mode 100644 ui/src/common/List.test.jsx create mode 100644 ui/src/common/Pagination.test.jsx create mode 100644 ui/src/common/perPageStore.js create mode 100644 ui/src/common/perPageStore.test.js create mode 100644 ui/src/common/useAlbumsPerPage.test.jsx diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index a860c85bb..5108bfaa1 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -6,7 +6,6 @@ import { Filter, NullableBooleanInput, NumberInput, - Pagination, ReferenceArrayInput, ReferenceInput, SearchInput, @@ -20,6 +19,7 @@ import FavoriteIcon from '@material-ui/icons/Favorite' import { withWidth } from '@material-ui/core' import { List, + Pagination, Title, useAlbumsPerPage, useResourceRefresh, diff --git a/ui/src/artist/ArtistShow.jsx b/ui/src/artist/ArtistShow.jsx index 935b0bab7..955a565d6 100644 --- a/ui/src/artist/ArtistShow.jsx +++ b/ui/src/artist/ArtistShow.jsx @@ -100,6 +100,7 @@ const ArtistShowLayout = (props) => { const rowsPerPageOptions = [1, 2, 3].map((option) => Math.trunc(option * (perPage / 3)), ) + // react-admin's Pagination on purpose: the common one would persist 30/60/90 under the album grid's key pagination = } diff --git a/ui/src/common/List.jsx b/ui/src/common/List.jsx index 72c2d9482..0ae089460 100644 --- a/ui/src/common/List.jsx +++ b/ui/src/common/List.jsx @@ -2,6 +2,7 @@ import React from 'react' import { List as RAList } from 'react-admin' import config from '../config' import { Pagination } from './Pagination' +import { defaultRowsPerPageOptions, getStoredPerPage } from './perPageStore' import { Title } from './index' export const List = (props) => { @@ -15,7 +16,7 @@ export const List = (props) => { /> } debounce={config.uiSearchDebounceMs} - perPage={15} + perPage={getStoredPerPage(resource, defaultRowsPerPageOptions)} pagination={} {...props} /> diff --git a/ui/src/common/List.test.jsx b/ui/src/common/List.test.jsx new file mode 100644 index 000000000..5bc4b910f --- /dev/null +++ b/ui/src/common/List.test.jsx @@ -0,0 +1,27 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { List } from './List' + +// Only stub the heavy react-admin List controller (data fetching, router sync); +// everything else, including our own Pagination/perPageStore wiring, stays real +// so a bad import (the bug this test guards against) throws on render. +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + List: ({ children }) =>
{children}
, + } +}) + +describe('List', () => { + it('renders without throwing and shows its children', () => { + render( + +
list content
+
, + ) + expect(screen.getByTestId('ra-list')).toBeInTheDocument() + expect(screen.getByText('list content')).toBeInTheDocument() + }) +}) diff --git a/ui/src/common/Pagination.jsx b/ui/src/common/Pagination.jsx index e17d9e63e..dd18961ce 100644 --- a/ui/src/common/Pagination.jsx +++ b/ui/src/common/Pagination.jsx @@ -1,6 +1,29 @@ -import React from 'react' -import { Pagination as RAPagination } from 'react-admin' +import React, { useCallback } from 'react' +import { + Pagination as RAPagination, + useListPaginationContext, +} from 'react-admin' +import { setStoredPerPage, defaultRowsPerPageOptions } from './perPageStore' -export const Pagination = (props) => ( - -) +export const Pagination = ({ + rowsPerPageOptions = defaultRowsPerPageOptions, + ...props +}) => { + const { resource, setPerPage } = useListPaginationContext() + // Persist only a selector-driven change: mount, URL params and responsive + // fallbacks never call setPerPage, so they can't overwrite the preference. + const handleSetPerPage = useCallback( + (value) => { + if (resource) setStoredPerPage(resource, value) + setPerPage(value) + }, + [resource, setPerPage], + ) + return ( + + ) +} diff --git a/ui/src/common/Pagination.test.jsx b/ui/src/common/Pagination.test.jsx new file mode 100644 index 000000000..a488aba91 --- /dev/null +++ b/ui/src/common/Pagination.test.jsx @@ -0,0 +1,62 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { Pagination } from './Pagination' + +// stub RA's Pagination so a test can invoke the injected setPerPage, i.e. +// simulate an actual rows-per-page selection +vi.mock('react-admin', async () => { + const React = await vi.importActual('react') + return { + Pagination: ({ setPerPage }) => + React.createElement( + 'button', + { onClick: () => setPerPage(50) }, + 'select 50', + ), + useListPaginationContext: vi.fn(), + } +}) + +describe('Pagination', () => { + let mockContext + let setPerPage + + beforeEach(async () => { + vi.clearAllMocks() + localStorage.clear() + setPerPage = vi.fn() + const { useListPaginationContext } = await import('react-admin') + mockContext = vi.mocked(useListPaginationContext) + }) + + const selectPerPage = () => fireEvent.click(screen.getByText('select 50')) + + it('persists the page size chosen in the selector', () => { + mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage }) + render() + selectPerPage() + expect(localStorage.getItem('perPage.song')).toEqual('50') + }) + + it('still applies the change to the list', () => { + mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage }) + render() + selectPerPage() + expect(setPerPage).toHaveBeenCalledWith(50) + }) + + it('does not persist a page size the user did not select', () => { + mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage }) + render() + expect(localStorage.getItem('perPage.song')).toBeNull() + }) + + it('does not persist without a resource in context', () => { + mockContext.mockReturnValue({ perPage: 15, setPerPage }) + render() + selectPerPage() + expect(localStorage.getItem('perPage.undefined')).toBeNull() + expect(setPerPage).toHaveBeenCalledWith(50) + }) +}) diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 362a0ced3..ac8d7f62c 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -10,6 +10,7 @@ export * from './DurationField' export * from './List' export * from './MultiLineTextField' export * from './Pagination' +export * from './perPageStore' export * from './PlayButton' export * from './QuickFilter' export * from './RangeField' diff --git a/ui/src/common/perPageStore.js b/ui/src/common/perPageStore.js new file mode 100644 index 000000000..52a8a8f29 --- /dev/null +++ b/ui/src/common/perPageStore.js @@ -0,0 +1,11 @@ +export const defaultRowsPerPageOptions = [15, 25, 50] + +const key = (resource) => `perPage.${resource}` + +export const getStoredPerPage = (resource, options, fallback = options[0]) => { + const stored = parseInt(localStorage.getItem(key(resource)), 10) + return options.includes(stored) ? stored : fallback +} + +export const setStoredPerPage = (resource, perPage) => + localStorage.setItem(key(resource), String(perPage)) diff --git a/ui/src/common/perPageStore.test.js b/ui/src/common/perPageStore.test.js new file mode 100644 index 000000000..3aeba3ad8 --- /dev/null +++ b/ui/src/common/perPageStore.test.js @@ -0,0 +1,40 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { getStoredPerPage, setStoredPerPage } from './perPageStore' + +const options = [15, 25, 50] + +describe('perPageStore', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('round-trips a stored value', () => { + setStoredPerPage('song', 25) + expect(getStoredPerPage('song', options, 15)).toEqual(25) + }) + + it('keys values per resource', () => { + setStoredPerPage('song', 25) + setStoredPerPage('playlist', 50) + expect(getStoredPerPage('song', options, 15)).toEqual(25) + expect(getStoredPerPage('playlist', options, 15)).toEqual(50) + }) + + it('returns the fallback when nothing is stored', () => { + expect(getStoredPerPage('song', options, 15)).toEqual(15) + }) + + it('returns the fallback for garbage values', () => { + localStorage.setItem('perPage.song', 'bogus') + expect(getStoredPerPage('song', options, 15)).toEqual(15) + }) + + it('returns the fallback when the stored value is not a valid option', () => { + setStoredPerPage('album', 90) + expect(getStoredPerPage('album', [18, 36, 72], 18)).toEqual(18) + }) + + it('defaults the fallback to the first option', () => { + expect(getStoredPerPage('song', options)).toEqual(15) + }) +}) diff --git a/ui/src/common/useAlbumsPerPage.jsx b/ui/src/common/useAlbumsPerPage.jsx index 6a02bdeb7..0fb5616c3 100644 --- a/ui/src/common/useAlbumsPerPage.jsx +++ b/ui/src/common/useAlbumsPerPage.jsx @@ -1,4 +1,5 @@ import { useSelector } from 'react-redux' +import { getStoredPerPage } from './perPageStore' const getPerPage = (width) => { if (width === 'xs') return 12 @@ -17,10 +18,15 @@ const getPerPageOptions = (width) => { } export const useAlbumsPerPage = (width) => { - const perPage = - useSelector( - (state) => state?.admin.resources?.album?.list?.params?.perPage, - ) || getPerPage(width) + const options = getPerPageOptions(width) + const sessionPerPage = useSelector( + (state) => state?.admin.resources?.album?.list?.params?.perPage, + ) + // Use the session value only when it's valid for the current width, so a + // size picked at a wider breakpoint can't leave an out-of-range selector. + const perPage = options.includes(sessionPerPage) + ? sessionPerPage + : getStoredPerPage('album', options, getPerPage(width)) - return [perPage, getPerPageOptions(width)] + return [perPage, options] } diff --git a/ui/src/common/useAlbumsPerPage.test.jsx b/ui/src/common/useAlbumsPerPage.test.jsx new file mode 100644 index 000000000..b194a6ef2 --- /dev/null +++ b/ui/src/common/useAlbumsPerPage.test.jsx @@ -0,0 +1,61 @@ +import { renderHook } from '@testing-library/react-hooks' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { useAlbumsPerPage } from './useAlbumsPerPage' +import { setStoredPerPage } from './perPageStore' + +vi.mock('react-redux', () => ({ + useSelector: vi.fn(), +})) + +describe('useAlbumsPerPage', () => { + let mockUseSelector + + beforeEach(async () => { + vi.clearAllMocks() + localStorage.clear() + const { useSelector } = await import('react-redux') + mockUseSelector = vi.mocked(useSelector) + }) + + const setReduxPerPage = (value) => + mockUseSelector.mockImplementation((selector) => + selector({ + admin: { + resources: { album: { list: { params: { perPage: value } } } }, + }, + }), + ) + + it('prefers the redux session value over the stored one', () => { + setReduxPerPage(36) + setStoredPerPage('album', 72) + const { result } = renderHook(() => useAlbumsPerPage('lg')) + expect(result.current[0]).toEqual(36) + }) + + it('falls back to the stored value on fresh load', () => { + setReduxPerPage(undefined) + setStoredPerPage('album', 72) + const { result } = renderHook(() => useAlbumsPerPage('lg')) + expect(result.current[0]).toEqual(72) + }) + + it('ignores stored values invalid for the current width', () => { + setReduxPerPage(undefined) + setStoredPerPage('album', 72) // valid for lg, not for md + const { result } = renderHook(() => useAlbumsPerPage('md')) + expect(result.current[0]).toEqual(12) + }) + + it('returns the responsive default when nothing is stored', () => { + setReduxPerPage(undefined) + const { result } = renderHook(() => useAlbumsPerPage('xl')) + expect(result.current).toEqual([36, [18, 36, 72]]) + }) + + it('ignores a redux value invalid for the current width', () => { + setReduxPerPage(72) // valid for lg, not for md + const { result } = renderHook(() => useAlbumsPerPage('md')) + expect(result.current[0]).toEqual(12) + }) +}) diff --git a/ui/src/missing/MissingFilesList.jsx b/ui/src/missing/MissingFilesList.jsx index 87d9f629f..a09f552d9 100644 --- a/ui/src/missing/MissingFilesList.jsx +++ b/ui/src/missing/MissingFilesList.jsx @@ -1,10 +1,15 @@ -import { List, SizeField, useResourceRefresh } from '../common/index' +import { + List, + Pagination, + SizeField, + getStoredPerPage, + useResourceRefresh, +} from '../common/index' import { Datagrid, DateField, TextField, downloadCSV, - Pagination, Filter, ReferenceInput, useTranslate, @@ -49,8 +54,10 @@ const BulkActionButtons = (props) => ( ) +const missingPerPageOptions = [50, 100, 200] + const MissingPagination = (props) => ( - + ) const MissingFilesList = (props) => { @@ -63,7 +70,7 @@ const MissingFilesList = (props) => { actions={} filters={} bulkActionButtons={} - perPage={50} + perPage={getStoredPerPage('missing', missingPerPageOptions)} pagination={} > diff --git a/ui/src/playlist/PlaylistShow.jsx b/ui/src/playlist/PlaylistShow.jsx index f0cb472b1..4e269be18 100644 --- a/ui/src/playlist/PlaylistShow.jsx +++ b/ui/src/playlist/PlaylistShow.jsx @@ -4,14 +4,21 @@ import { ShowContextProvider, useShowContext, useShowController, - Pagination, Title as RaTitle, } from 'react-admin' import { makeStyles } from '@material-ui/core/styles' import PlaylistDetails from './PlaylistDetails' import PlaylistSongs from './PlaylistSongs' import PlaylistActions from './PlaylistActions' -import { Title, canChangeTracks, useResourceRefresh } from '../common' +import { + Pagination, + Title, + canChangeTracks, + getStoredPerPage, + useResourceRefresh, +} from '../common' + +const playlistTrackPerPageOptions = [100, 250, 500] const useStyles = makeStyles( (theme) => ({ @@ -41,7 +48,10 @@ const PlaylistShowLayout = (props) => { reference="playlistTrack" target="playlist_id" sort={{ field: 'id', order: 'ASC' }} - perPage={100} + perPage={getStoredPerPage( + 'playlistTrack', + playlistTrackPerPageOptions, + )} filter={{ playlist_id: props.id }} > { } resource={'playlistTrack'} exporter={false} - pagination={} + pagination={ + + } /> )} diff --git a/ui/src/radio/RadioList.jsx b/ui/src/radio/RadioList.jsx index 945bac519..ccdb9f1ef 100644 --- a/ui/src/radio/RadioList.jsx +++ b/ui/src/radio/RadioList.jsx @@ -16,6 +16,8 @@ import { } from 'react-admin' import { List, + defaultRowsPerPageOptions, + getStoredPerPage, useImageUrl, ToggleFieldsMenu, useSelectedFields, @@ -135,7 +137,11 @@ const RadioList = ({ permissions, ...props }) => { hasCreate={isAdmin} actions={} filters={} - perPage={isXsmall ? 25 : 10} + perPage={getStoredPerPage( + 'radio', + defaultRowsPerPageOptions, + isXsmall ? 25 : 10, + )} > {isXsmall ? ( { bulkActionButtons={} actions={} filters={} - perPage={isXsmall ? 50 : 15} + perPage={getStoredPerPage( + 'song', + defaultRowsPerPageOptions, + isXsmall ? 50 : 15, + )} > {isXsmall ? ( From c35b14dd74d35660a4781d3bd72f7f9c8efa8bd9 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 20 Jul 2026 09:49:48 -0400 Subject: [PATCH 17/19] chore(deps): update go-taglib to v2.3.1 Signed-off-by: Deluan --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 014a43a56..5488b41e4 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/navidrome/navidrome go 1.26 // Fork to implement raw tags support -replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d +replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 require ( github.com/Masterminds/squirrel v1.5.4 diff --git a/go.sum b/go.sum index 064974edb..29983a27d 100644 --- a/go.sum +++ b/go.sum @@ -31,8 +31,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d h1:/MmnVPIlGzX5kYF6sNtMaOHMkjmu0Us7WtDyJZTglMs= -github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= +github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 h1:j7eSXqgtjhlNfwnMEzRdXnJGZTEw4I7J9TeQAll83bU= +github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8= github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4= From 62257527d7f24fdf90b717f12827e708f6223033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 20 Jul 2026 21:49:14 -0400 Subject: [PATCH 18/19] fix(subsonic): avoid double brackets when appending subtitle or version (#5832) When AppendSubtitle or AppendAlbumVersion is enabled, the subtitle/version tag was always wrapped in parentheses and appended to the title/album name. If the tag value already came wrapped in brackets (e.g. "(non-explicit version)"), the result was doubled: "Title ((non-explicit version))". Append the tag as-is when it is already wrapped in a matching bracket pair - (), [], {} or <> - and trim surrounding whitespace first. The shared appendSuffix helper is used by MediaFile.FullTitle, MediaFile.FullAlbumName and Album.FullName so all consumers behave consistently. --- model/album.go | 3 +-- model/album_test.go | 2 ++ model/mediafile.go | 14 ++++++++++++-- model/mediafile_test.go | 9 +++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/model/album.go b/model/album.go index 0afeb2760..55df8c63c 100644 --- a/model/album.go +++ b/model/album.go @@ -1,7 +1,6 @@ package model import ( - "fmt" "iter" "math" "sync" @@ -77,7 +76,7 @@ func (a Album) CoverArtID() ArtworkID { func (a Album) FullName() string { if conf.Server.Subsonic.AppendAlbumVersion && len(a.Tags[TagAlbumVersion]) > 0 { - return fmt.Sprintf("%s (%s)", a.Name, a.Tags[TagAlbumVersion][0]) + return appendSuffix(a.Name, a.Tags[TagAlbumVersion][0]) } return a.Name } diff --git a/model/album_test.go b/model/album_test.go index 0f4c912cd..ad2ca1cb6 100644 --- a/model/album_test.go +++ b/model/album_test.go @@ -24,6 +24,8 @@ var _ = Describe("Album", func() { Entry("returns just name when disabled", false, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album"), Entry("returns just name when tag is absent", true, Tags{}, "Album"), Entry("returns just name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"), + Entry("does not double parentheses when version is already parenthesized", true, Tags{TagAlbumVersion: []string{"(Remastered)"}}, "Album (Remastered)"), + Entry("does not add parentheses when version is wrapped in square brackets", true, Tags{TagAlbumVersion: []string{"[Remastered]"}}, "Album [Remastered]"), ) }) diff --git a/model/mediafile.go b/model/mediafile.go index 910cd998d..22ab7fbbe 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -100,18 +100,28 @@ type MediaFile struct { func (mf MediaFile) FullTitle() string { if conf.Server.Subsonic.AppendSubtitle && len(mf.Tags[TagSubtitle]) > 0 { - return fmt.Sprintf("%s (%s)", mf.Title, mf.Tags[TagSubtitle][0]) + return appendSuffix(mf.Title, mf.Tags[TagSubtitle][0]) } return mf.Title } func (mf MediaFile) FullAlbumName() string { if conf.Server.Subsonic.AppendAlbumVersion && len(mf.Tags[TagAlbumVersion]) > 0 { - return fmt.Sprintf("%s (%s)", mf.Album, mf.Tags[TagAlbumVersion][0]) + return appendSuffix(mf.Album, mf.Tags[TagAlbumVersion][0]) } return mf.Album } +var bracketPairs = map[byte]byte{'(': ')', '[': ']', '{': '}', '<': '>'} + +func appendSuffix(base, suffix string) string { + suffix = strings.TrimSpace(suffix) + if len(suffix) >= 2 && bracketPairs[suffix[0]] == suffix[len(suffix)-1] { + return base + " " + suffix + } + return base + " (" + suffix + ")" +} + func (mf MediaFile) ContentType() string { return mime.TypeByExtension("." + mf.Suffix) } diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 6085ccf25..3f306f1a7 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -533,6 +533,13 @@ var _ = Describe("MediaFile", func() { Entry("returns just title when disabled", false, Tags{TagSubtitle: []string{"Live"}}, "Song"), Entry("returns just title when tag is absent", true, Tags{}, "Song"), Entry("returns just title when tag is an empty slice", true, Tags{TagSubtitle: []string{}}, "Song"), + Entry("does not double parentheses when subtitle is already parenthesized", true, Tags{TagSubtitle: []string{"(non-explicit version)"}}, "Song (non-explicit version)"), + Entry("does not add parentheses when subtitle is wrapped in square brackets", true, Tags{TagSubtitle: []string{"[Live]"}}, "Song [Live]"), + Entry("does not add parentheses when subtitle is wrapped in curly braces", true, Tags{TagSubtitle: []string{"{Remix}"}}, "Song {Remix}"), + Entry("does not add parentheses when subtitle is wrapped in angle brackets", true, Tags{TagSubtitle: []string{""}}, "Song "), + Entry("adds parentheses when brackets do not match", true, Tags{TagSubtitle: []string{"[Live)"}}, "Song ([Live))"), + Entry("trims surrounding whitespace before wrapping", true, Tags{TagSubtitle: []string{" Live "}}, "Song (Live)"), + Entry("trims whitespace around an already-bracketed subtitle", true, Tags{TagSubtitle: []string{" (Live) "}}, "Song (Live)"), ) DescribeTable("FullAlbumName", func(enabled bool, tags Tags, expected string) { @@ -544,6 +551,8 @@ var _ = Describe("MediaFile", func() { Entry("returns just album name when disabled", false, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album"), Entry("returns just album name when tag is absent", true, Tags{}, "Album"), Entry("returns just album name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"), + Entry("does not double parentheses when version is already parenthesized", true, Tags{TagAlbumVersion: []string{"(Deluxe Edition)"}}, "Album (Deluxe Edition)"), + Entry("does not add parentheses when version is wrapped in square brackets", true, Tags{TagAlbumVersion: []string{"[Deluxe Edition]"}}, "Album [Deluxe Edition]"), ) Describe("CoverArtId", func() { It("returns its own id if it HasCoverArt", func() { From 93f6afb684b44b29ebdbd35eb18d3f063d14caa6 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 12:19:50 -0400 Subject: [PATCH 19/19] chore: add OpenCollective funding option Signed-off-by: Deluan --- .github/FUNDING.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 534cfbd11..e5ed36e70 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,10 +1,10 @@ # These are supported funding model platforms -github: deluan -patreon: # Replace with a single Patreon username -open_collective: # Replace with a single Open Collective username ko_fi: deluan +github: deluan +open_collective: navidrome liberapay: deluan +patreon: # Replace with a single Patreon username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry issuehunt: # Replace with a single IssueHunt username