diff --git a/ui/src/common/useRating.jsx b/ui/src/common/useRating.jsx
index f1d9a4fe9..2eb5d9eca 100644
--- a/ui/src/common/useRating.jsx
+++ b/ui/src/common/useRating.jsx
@@ -17,18 +17,42 @@ export const useRating = (resource, record) => {
}, [])
const refreshRating = useCallback(() => {
- dataProvider
- .getOne(resource, { id: record.id })
- .then(() => {
- if (mountedRef.current) {
- setLoading(false)
- }
- })
- .catch((e) => {
- // eslint-disable-next-line no-console
- console.log('Error encountered: ' + e)
- })
- }, [dataProvider, record, resource])
+ // For playlist tracks, refresh both resources to keep data in sync
+ if (record.mediaFileId) {
+ // This is a playlist track - refresh both the playlist track and the song
+ const promises = [
+ dataProvider.getOne('song', { id: record.mediaFileId }),
+ dataProvider.getOne('playlistTrack', {
+ id: record.id,
+ filter: { playlist_id: record.playlistId },
+ }),
+ ]
+
+ Promise.all(promises)
+ .catch((e) => {
+ // eslint-disable-next-line no-console
+ console.log('Error encountered: ' + e)
+ })
+ .finally(() => {
+ if (mountedRef.current) {
+ setLoading(false)
+ }
+ })
+ } else {
+ // Regular song or other resource
+ dataProvider
+ .getOne(resource, { id: record.id })
+ .catch((e) => {
+ // eslint-disable-next-line no-console
+ console.log('Error encountered: ' + e)
+ })
+ .finally(() => {
+ if (mountedRef.current) {
+ setLoading(false)
+ }
+ })
+ }
+ }, [dataProvider, record.id, record.mediaFileId, record.playlistId, resource])
const rate = (val, id) => {
setLoading(true)
diff --git a/ui/src/common/useRating.test.js b/ui/src/common/useRating.test.js
new file mode 100644
index 000000000..b1353512e
--- /dev/null
+++ b/ui/src/common/useRating.test.js
@@ -0,0 +1,165 @@
+import { renderHook, act } from '@testing-library/react-hooks'
+import { vi, describe, it, expect, beforeEach } from 'vitest'
+import { useRating } from './useRating'
+import subsonic from '../subsonic'
+import { useDataProvider } from 'react-admin'
+
+vi.mock('../subsonic', () => ({
+ default: {
+ setRating: vi.fn(() => Promise.resolve()),
+ },
+}))
+
+vi.mock('react-admin', async () => {
+ const actual = await vi.importActual('react-admin')
+ return {
+ ...actual,
+ useDataProvider: vi.fn(),
+ useNotify: vi.fn(() => vi.fn()),
+ }
+})
+
+describe('useRating', () => {
+ let getOne
+ beforeEach(() => {
+ getOne = vi.fn(() => Promise.resolve())
+ useDataProvider.mockReturnValue({ getOne })
+ vi.clearAllMocks()
+ })
+
+ it('returns rating value from record', () => {
+ const record = { id: 'sg-1', rating: 3 }
+ const { result } = renderHook(() => useRating('song', record))
+ const [rate, rating, loading] = result.current
+ expect(rating).toBe(3)
+ expect(loading).toBe(false)
+ expect(typeof rate).toBe('function')
+ })
+
+ it('sets rating using targetId and calls setRating API', async () => {
+ const record = { id: 'sg-1', rating: 0 }
+ const { result } = renderHook(() => useRating('song', record))
+ await act(async () => {
+ await result.current[0](4, 'sg-1')
+ })
+ expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 4)
+ expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
+ })
+
+ it('handles zero rating (unrate)', async () => {
+ const record = { id: 'sg-1', rating: 5 }
+ const { result } = renderHook(() => useRating('song', record))
+ await act(async () => {
+ await result.current[0](0, 'sg-1')
+ })
+ expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 0)
+ })
+
+ describe('playlist track scenarios', () => {
+ it('refreshes both playlist track and song for playlist tracks', async () => {
+ const record = {
+ id: 'pt-1',
+ mediaFileId: 'sg-1',
+ playlistId: 'pl-1',
+ rating: 2,
+ }
+ const { result } = renderHook(() => useRating('playlistTrack', record))
+ await act(async () => {
+ await result.current[0](5, 'sg-1')
+ })
+
+ // Should rate using the media file ID
+ expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 5)
+
+ // Should refresh both the playlist track and the song
+ expect(getOne).toHaveBeenCalledTimes(2)
+ expect(getOne).toHaveBeenCalledWith('playlistTrack', {
+ id: 'pt-1',
+ filter: { playlist_id: 'pl-1' },
+ })
+ expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
+ })
+
+ it('includes playlist_id filter when refreshing playlist tracks', async () => {
+ const record = {
+ id: 'pt-5',
+ mediaFileId: 'sg-10',
+ playlistId: 'pl-123',
+ rating: 1,
+ }
+ const { result } = renderHook(() => useRating('playlistTrack', record))
+ await act(async () => {
+ await result.current[0](3, 'sg-10')
+ })
+
+ // Should rate using the media file ID
+ expect(subsonic.setRating).toHaveBeenCalledWith('sg-10', 3)
+
+ // Should refresh playlist track with correct playlist_id filter
+ expect(getOne).toHaveBeenCalledWith('playlistTrack', {
+ id: 'pt-5',
+ filter: { playlist_id: 'pl-123' },
+ })
+ // Should also refresh the underlying song
+ expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-10' })
+ })
+
+ it('only refreshes original resource when no mediaFileId present', async () => {
+ const record = { id: 'sg-1', rating: 4 }
+ const { result } = renderHook(() => useRating('song', record))
+ await act(async () => {
+ await result.current[0](2, 'sg-1')
+ })
+
+ // Should only refresh the original resource (song)
+ expect(getOne).toHaveBeenCalledTimes(1)
+ expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
+ })
+
+ it('does not include playlist_id filter for non-playlist resources', async () => {
+ const record = { id: 'sg-1', rating: 0 }
+ const { result } = renderHook(() => useRating('song', record))
+ await act(async () => {
+ await result.current[0](5, 'sg-1')
+ })
+
+ // Should refresh without any filter
+ expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
+ })
+ })
+
+ describe('component integration scenarios', () => {
+ it('handles mediaFileId fallback correctly for playlist tracks', async () => {
+ const record = {
+ id: 'pt-1',
+ mediaFileId: 'sg-1',
+ playlistId: 'pl-1',
+ rating: 0,
+ }
+ const { result } = renderHook(() => useRating('playlistTrack', record))
+
+ // Simulate RatingField component behavior: uses mediaFileId || record.id
+ const targetId = record.mediaFileId || record.id
+ await act(async () => {
+ await result.current[0](4, targetId)
+ })
+
+ expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 4)
+ })
+
+ it('handles regular song rating without mediaFileId', async () => {
+ const record = { id: 'sg-1', rating: 2 }
+ const { result } = renderHook(() => useRating('song', record))
+
+ // Simulate RatingField component behavior: uses mediaFileId || record.id
+ const targetId = record.mediaFileId || record.id
+ await act(async () => {
+ await result.current[0](5, targetId)
+ })
+
+ expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 5)
+ expect(getOne).toHaveBeenCalledTimes(1)
+ expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
+ })
+ })
+})
diff --git a/ui/src/common/useToggleLove.jsx b/ui/src/common/useToggleLove.jsx
index 6379d587e..3f98a2e21 100644
--- a/ui/src/common/useToggleLove.jsx
+++ b/ui/src/common/useToggleLove.jsx
@@ -17,18 +17,38 @@ export const useToggleLove = (resource, record = {}) => {
const dataProvider = useDataProvider()
const refreshRecord = useCallback(() => {
- dataProvider.getOne(resource, { id: record.id }).then(() => {
- if (mountedRef.current) {
- setLoading(false)
- }
- })
- }, [dataProvider, record.id, resource])
+ const promises = []
+
+ // Always refresh the original resource
+ const params = { id: record.id }
+ if (record.playlistId) {
+ params.filter = { playlist_id: record.playlistId }
+ }
+ promises.push(dataProvider.getOne(resource, params))
+
+ // If we have a mediaFileId, also refresh the song
+ if (record.mediaFileId) {
+ promises.push(dataProvider.getOne('song', { id: record.mediaFileId }))
+ }
+
+ Promise.all(promises)
+ .catch((e) => {
+ // eslint-disable-next-line no-console
+ console.log('Error encountered: ' + e)
+ })
+ .finally(() => {
+ if (mountedRef.current) {
+ setLoading(false)
+ }
+ })
+ }, [dataProvider, record.mediaFileId, record.id, record.playlistId, resource])
const toggleLove = () => {
const toggle = record.starred ? subsonic.unstar : subsonic.star
+ const id = record.mediaFileId || record.id
setLoading(true)
- toggle(record.id)
+ toggle(id)
.then(refreshRecord)
.catch((e) => {
// eslint-disable-next-line no-console
diff --git a/ui/src/common/useToggleLove.test.js b/ui/src/common/useToggleLove.test.js
new file mode 100644
index 000000000..640e9ff89
--- /dev/null
+++ b/ui/src/common/useToggleLove.test.js
@@ -0,0 +1,136 @@
+import { renderHook, act } from '@testing-library/react-hooks'
+import { vi, describe, it, expect, beforeEach } from 'vitest'
+import { useToggleLove } from './useToggleLove'
+import subsonic from '../subsonic'
+import { useDataProvider } from 'react-admin'
+
+vi.mock('../subsonic', () => ({
+ default: {
+ star: vi.fn(() => Promise.resolve()),
+ unstar: vi.fn(() => Promise.resolve()),
+ },
+}))
+
+vi.mock('react-admin', async () => {
+ const actual = await vi.importActual('react-admin')
+ return {
+ ...actual,
+ useDataProvider: vi.fn(),
+ useNotify: vi.fn(() => vi.fn()),
+ }
+})
+
+describe('useToggleLove', () => {
+ let getOne
+ beforeEach(() => {
+ getOne = vi.fn(() => Promise.resolve())
+ useDataProvider.mockReturnValue({ getOne })
+ vi.clearAllMocks()
+ })
+
+ it('uses mediaFileId when present', async () => {
+ const record = { id: 'pt-1', mediaFileId: 'sg-1', starred: false }
+ const { result } = renderHook(() => useToggleLove('song', record))
+ await act(async () => {
+ await result.current[0]()
+ })
+ expect(subsonic.star).toHaveBeenCalledWith('sg-1')
+ expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
+ })
+
+ it('falls back to id when mediaFileId not present', async () => {
+ const record = { id: 'sg-1', starred: false }
+ const { result } = renderHook(() => useToggleLove('song', record))
+ await act(async () => {
+ await result.current[0]()
+ })
+ expect(subsonic.star).toHaveBeenCalledWith('sg-1')
+ expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
+ })
+
+ it('calls unstar when record is already loved', async () => {
+ const record = { id: 'sg-1', starred: true }
+ const { result } = renderHook(() => useToggleLove('song', record))
+ await act(async () => {
+ await result.current[0]()
+ })
+ expect(subsonic.unstar).toHaveBeenCalledWith('sg-1')
+ })
+
+ describe('playlist track scenarios', () => {
+ it('refreshes both playlist track and song for playlist tracks', async () => {
+ const record = {
+ id: 'pt-1',
+ mediaFileId: 'sg-1',
+ playlistId: 'pl-1',
+ starred: false,
+ }
+ const { result } = renderHook(() =>
+ useToggleLove('playlistTrack', record),
+ )
+ await act(async () => {
+ await result.current[0]()
+ })
+
+ // Should star using the media file ID
+ expect(subsonic.star).toHaveBeenCalledWith('sg-1')
+
+ // Should refresh both the playlist track and the song
+ expect(getOne).toHaveBeenCalledTimes(2)
+ expect(getOne).toHaveBeenCalledWith('playlistTrack', {
+ id: 'pt-1',
+ filter: { playlist_id: 'pl-1' },
+ })
+ expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
+ })
+
+ it('includes playlist_id filter when refreshing playlist tracks', async () => {
+ const record = {
+ id: 'pt-5',
+ mediaFileId: 'sg-10',
+ playlistId: 'pl-123',
+ starred: true,
+ }
+ const { result } = renderHook(() =>
+ useToggleLove('playlistTrack', record),
+ )
+ await act(async () => {
+ await result.current[0]()
+ })
+
+ // Should unstar using the media file ID
+ expect(subsonic.unstar).toHaveBeenCalledWith('sg-10')
+
+ // Should refresh playlist track with correct playlist_id filter
+ expect(getOne).toHaveBeenCalledWith('playlistTrack', {
+ id: 'pt-5',
+ filter: { playlist_id: 'pl-123' },
+ })
+ // Should also refresh the underlying song
+ expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-10' })
+ })
+
+ it('only refreshes original resource when no mediaFileId present', async () => {
+ const record = { id: 'sg-1', starred: false }
+ const { result } = renderHook(() => useToggleLove('song', record))
+ await act(async () => {
+ await result.current[0]()
+ })
+
+ // Should only refresh the original resource (song)
+ expect(getOne).toHaveBeenCalledTimes(1)
+ expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
+ })
+
+ it('does not include playlist_id filter for non-playlist resources', async () => {
+ const record = { id: 'sg-1', starred: false }
+ const { result } = renderHook(() => useToggleLove('song', record))
+ await act(async () => {
+ await result.current[0]()
+ })
+
+ // Should refresh without any filter
+ expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' })
+ })
+ })
+})
diff --git a/ui/src/config.js b/ui/src/config.js
index 92ce07893..1a89019ba 100644
--- a/ui/src/config.js
+++ b/ui/src/config.js
@@ -30,6 +30,7 @@ const defaultConfig = {
enableExternalServices: true,
enableCoverAnimation: true,
devShowArtistPage: true,
+ devUIShowConfig: true,
enableReplayGain: true,
defaultDownsamplingFormat: 'opus',
publicBaseUrl: '/share',
diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js
index bf487dc7c..257a274e8 100644
--- a/ui/src/dataProvider/wrapperDataProvider.js
+++ b/ui/src/dataProvider/wrapperDataProvider.js
@@ -90,6 +90,16 @@ const wrapperDataProvider = {
body: JSON.stringify(data),
}).then(({ json }) => ({ data: json }))
},
+ getPlaylists: (songId) => {
+ return httpClient(`${REST_URL}/song/${songId}/playlists`).then(
+ ({ json }) => ({ data: json }),
+ )
+ },
+ inspect: (songId) => {
+ return httpClient(`${REST_URL}/inspect?id=${songId}`).then(({ json }) => ({
+ data: json,
+ }))
+ },
}
export default wrapperDataProvider
diff --git a/ui/src/dialogs/AboutDialog.jsx b/ui/src/dialogs/AboutDialog.jsx
index 4f074002b..cb605cde1 100644
--- a/ui/src/dialogs/AboutDialog.jsx
+++ b/ui/src/dialogs/AboutDialog.jsx
@@ -10,14 +10,63 @@ import TableRow from '@material-ui/core/TableRow'
import TableCell from '@material-ui/core/TableCell'
import Paper from '@material-ui/core/Paper'
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
+import FileCopyIcon from '@material-ui/icons/FileCopy'
+import Button from '@material-ui/core/Button'
import { humanize, underscore } from 'inflection'
-import { useGetOne, usePermissions, useTranslate } from 'react-admin'
+import { useGetOne, usePermissions, useTranslate, useNotify } from 'react-admin'
+import { Tabs, Tab } from '@material-ui/core'
+import { makeStyles } from '@material-ui/core/styles'
import config from '../config'
import { DialogTitle } from './DialogTitle'
import { DialogContent } from './DialogContent'
import { INSIGHTS_DOC_URL } from '../consts.js'
import subsonic from '../subsonic/index.js'
import { Typography } from '@material-ui/core'
+import TableHead from '@material-ui/core/TableHead'
+import { configToToml, separateAndSortConfigs } from './aboutUtils'
+
+const useStyles = makeStyles((theme) => ({
+ configNameColumn: {
+ maxWidth: '200px',
+ width: '200px',
+ wordWrap: 'break-word',
+ overflowWrap: 'break-word',
+ },
+ envVarColumn: {
+ maxWidth: '200px',
+ width: '200px',
+ fontFamily: 'monospace',
+ wordWrap: 'break-word',
+ overflowWrap: 'break-word',
+ },
+ configFileValue: {
+ maxWidth: '300px',
+ width: '300px',
+ fontFamily: 'monospace',
+ wordBreak: 'break-all',
+ },
+ copyButton: {
+ marginBottom: theme.spacing(2),
+ marginTop: theme.spacing(1),
+ },
+ devSectionHeader: {
+ '& td': {
+ paddingTop: theme.spacing(2),
+ paddingBottom: theme.spacing(2),
+ borderTop: `2px solid ${theme.palette.divider}`,
+ borderBottom: `1px solid ${theme.palette.divider}`,
+ textAlign: 'left',
+ fontWeight: 600,
+ },
+ },
+ configContainer: {
+ paddingTop: theme.spacing(1),
+ },
+ tableContainer: {
+ maxHeight: '60vh',
+ overflow: 'auto',
+ },
+}))
const links = {
homepage: 'navidrome.org',
@@ -54,7 +103,6 @@ const LinkToVersion = ({ version }) => {
const ShowVersion = ({ uiVersion, serverVersion }) => {
const translate = useTranslate()
-
const showRefresh = uiVersion !== serverVersion
return (
@@ -73,12 +121,16 @@ const ShowVersion = ({ uiVersion, serverVersion }) => {
UI {translate('menu.version')}:
-
- window.location.reload()}>
-
- {' ' + translate('ra.notification.new_version')}
-
-
+
+
+
+
+ window.location.reload()}>
+
+ {translate('ra.notification.new_version')}
+
+
+
)}
@@ -86,11 +138,286 @@ const ShowVersion = ({ uiVersion, serverVersion }) => {
)
}
-const AboutDialog = ({ open, onClose }) => {
+const AboutTabContent = ({
+ uiVersion,
+ serverVersion,
+ insightsData,
+ loading,
+ permissions,
+}) => {
const translate = useTranslate()
+
+ const lastRun = !loading && insightsData?.lastRun
+ let insightsStatus = 'N/A'
+ if (lastRun === 'disabled') {
+ insightsStatus = translate('about.links.insights.disabled')
+ } else if (lastRun && lastRun?.startsWith('1969-12-31')) {
+ insightsStatus = translate('about.links.insights.waiting')
+ } else if (lastRun) {
+ insightsStatus = lastRun
+ }
+
+ return (
+
+
+
+ {Object.keys(links).map((key) => {
+ return (
+
+
+ {translate(`about.links.${key}`, {
+ _: humanize(underscore(key)),
+ })}
+ :
+
+
+
+ {links[key]}
+
+
+
+ )
+ })}
+ {permissions === 'admin' ? (
+
+
+ {translate(`about.links.lastInsightsCollection`)}:
+
+
+ {insightsStatus}
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+ ko-fi.com/deluan
+
+
+
+
+
+ )
+}
+
+const ConfigTabContent = ({ configData }) => {
+ const classes = useStyles()
+ const translate = useTranslate()
+ const notify = useNotify()
+
+ if (!configData || !configData.config) {
+ return null
+ }
+
+ // Use the shared separation and sorting logic
+ const { regularConfigs, devConfigs } = separateAndSortConfigs(
+ configData.config,
+ )
+
+ const handleCopyToml = async () => {
+ try {
+ const tomlContent = configToToml(configData, translate)
+ await navigator.clipboard.writeText(tomlContent)
+ notify(translate('about.config.exportSuccess'), 'info')
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.error('Failed to copy TOML:', err)
+ notify(translate('about.config.exportFailed'), 'error')
+ }
+ }
+
+ return (
+
+
}
+ onClick={handleCopyToml}
+ className={classes.copyButton}
+ disabled={!configData}
+ size="small"
+ >
+ {translate('about.config.exportToml')}
+
+
+
+
+
+
+ {translate('about.config.configName')}
+
+
+ {translate('about.config.environmentVariable')}
+
+
+ {translate('about.config.currentValue')}
+
+
+
+
+ {configData?.configFile && (
+
+
+ {translate('about.config.configurationFile')}
+
+
+ ND_CONFIGFILE
+
+
+ {configData.configFile}
+
+
+ )}
+ {regularConfigs.map(({ key, envVar, value }) => (
+
+
+ {key}
+
+
+ {envVar}
+
+ {String(value)}
+
+ ))}
+ {devConfigs.length > 0 && (
+
+
+
+ 🚧 {translate('about.config.devFlagsHeader')}
+
+
+
+ )}
+ {devConfigs.map(({ key, envVar, value }) => (
+
+
+ {key}
+
+
+ {envVar}
+
+ {String(value)}
+
+ ))}
+
+
+
+
+ )
+}
+
+const TabContent = ({
+ tab,
+ setTab,
+ showConfigTab,
+ uiVersion,
+ serverVersion,
+ insightsData,
+ loading,
+ permissions,
+ configData,
+}) => {
+ const translate = useTranslate()
+
+ return (
+
+ {showConfigTab && (
+ setTab(value)}>
+
+
+
+ )}
+
+ {showConfigTab && (
+
+
+
+ )}
+
+ )
+}
+
+const AboutDialog = ({ open, onClose }) => {
const { permissions } = usePermissions()
- const { data, loading } = useGetOne('insights', 'insights_status')
+ const { data: insightsData, loading } = useGetOne(
+ 'insights',
+ 'insights_status',
+ )
const [serverVersion, setServerVersion] = useState('')
+ const showConfigTab = permissions === 'admin' && config.devUIShowConfig
+ const [tab, setTab] = useState(0)
+ const { data: configData } = useGetOne('config', 'config', {
+ enabled: showConfigTab,
+ })
+ const expanded = showConfigTab && tab === 1
const uiVersion = config.version
useEffect(() => {
@@ -108,85 +435,30 @@ const AboutDialog = ({ open, onClose }) => {
})
}, [setServerVersion])
- const lastRun = !loading && data?.lastRun
- let insightsStatus = 'N/A'
- if (lastRun === 'disabled') {
- insightsStatus = translate('about.links.insights.disabled')
- } else if (lastRun && lastRun?.startsWith('1969-12-31')) {
- insightsStatus = translate('about.links.insights.waiting')
- } else if (lastRun) {
- insightsStatus = lastRun
- }
-
return (
-