-
+
({
+ default: {
+ getAlbumInfo: () =>
+ Promise.resolve({
+ json: { 'subsonic-response': { status: 'ok', albumInfo: {} } },
+ }),
+ getCoverArtUrl: () => '',
+ },
+}))
+
+vi.mock('react-admin', async () => {
+ const actual = await vi.importActual('react-admin')
+ return {
+ ...actual,
+ useDataProvider: () => ({ getOne: vi.fn() }),
+ useNotify: () => vi.fn(),
+ useRefresh: () => vi.fn(),
+ }
+})
// Mock useMediaQuery
vi.mock('@material-ui/core', async () => {
@@ -343,3 +365,49 @@ describe('Details component', () => {
})
})
})
+
+describe('AlbumDetails cover animation', () => {
+ const albumRecord = {
+ id: '123',
+ name: 'Test Album',
+ songCount: 12,
+ duration: 3600,
+ size: 102400,
+ }
+ const originalEnableCoverAnimation = config.enableCoverAnimation
+
+ beforeEach(() => {
+ vi.mocked(useMediaQuery).mockReturnValue(false)
+ })
+
+ afterEach(() => {
+ config.enableCoverAnimation = originalEnableCoverAnimation
+ })
+
+ const renderAlbum = () =>
+ render(
+
+
+
+
+ ,
+ )
+
+ test('applies noCoverAnimation when enableCoverAnimation is false', () => {
+ config.enableCoverAnimation = false
+ const { container } = renderAlbum()
+ const cover = container.querySelector('[class*="coverParent"]')
+
+ expect(cover).not.toBeNull()
+ expect(cover.className).toMatch(/noCoverAnimation/)
+ })
+
+ test('omits noCoverAnimation when enableCoverAnimation is true', () => {
+ config.enableCoverAnimation = true
+ const { container } = renderAlbum()
+ const cover = container.querySelector('[class*="coverParent"]')
+
+ expect(cover).not.toBeNull()
+ expect(cover.className).not.toMatch(/noCoverAnimation/)
+ })
+})
diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx
index 3cb8a648e..81e4dcd14 100644
--- a/ui/src/album/AlbumList.jsx
+++ b/ui/src/album/AlbumList.jsx
@@ -163,6 +163,7 @@ const AlbumFilter = (props) => {
/>
+
{config.enableFavourites && (
{
removeItems()
- if (config.extAuthLogoutURL) {
+ // Only proxy-authenticated sessions go to the IdP; others (e.g. direct LAN access) get the login form
+ if (config.extAuthLogoutURL && config.auth) {
window.location.href = config.extAuthLogoutURL
return Promise.resolve(false)
}
@@ -98,7 +95,6 @@ const authProvider = {
id: localStorage.getItem('username'),
fullName: localStorage.getItem('name'),
avatar: localStorage.getItem('avatar'),
- avatarTag: localStorage.getItem('avatarTag'),
})
},
}
@@ -109,7 +105,6 @@ const removeItems = () => {
localStorage.removeItem('name')
localStorage.removeItem('username')
localStorage.removeItem('avatar')
- localStorage.removeItem('avatarTag')
localStorage.removeItem('role')
localStorage.removeItem('subsonic-salt')
localStorage.removeItem('subsonic-token')
diff --git a/ui/src/authProvider.test.js b/ui/src/authProvider.test.js
new file mode 100644
index 000000000..b16a39815
--- /dev/null
+++ b/ui/src/authProvider.test.js
@@ -0,0 +1,47 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import config from './config'
+import authProvider from './authProvider'
+
+vi.mock('./config', () => ({ default: {} }))
+
+describe('authProvider.logout', () => {
+ const logoutURL = 'https://auth.example.com/signout'
+
+ beforeEach(() => {
+ vi.stubGlobal('location', { href: '' })
+ localStorage.setItem('is-authenticated', 'true')
+ localStorage.setItem('token', 'abc')
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ localStorage.clear()
+ delete config.extAuthLogoutURL
+ delete config.auth
+ })
+
+ it('clears the stored session', async () => {
+ await authProvider.logout()
+ expect(localStorage.getItem('is-authenticated')).toBeNull()
+ expect(localStorage.getItem('token')).toBeNull()
+ })
+
+ it('does not redirect when no logout URL is configured', async () => {
+ config.auth = { id: '1' }
+ await expect(authProvider.logout()).resolves.toBeUndefined()
+ expect(window.location.href).toBe('')
+ })
+
+ it('redirects to the logout URL when the page was authenticated by the proxy', async () => {
+ config.extAuthLogoutURL = logoutURL
+ config.auth = { id: '1' }
+ await expect(authProvider.logout()).resolves.toBe(false)
+ expect(window.location.href).toBe(logoutURL)
+ })
+
+ it('does not redirect when the page was not authenticated by the proxy', async () => {
+ config.extAuthLogoutURL = logoutURL
+ await expect(authProvider.logout()).resolves.toBeUndefined()
+ expect(window.location.href).toBe('')
+ })
+})
diff --git a/ui/src/common/DateField.jsx b/ui/src/common/DateField.jsx
index dce24a2b9..dac9cff08 100644
--- a/ui/src/common/DateField.jsx
+++ b/ui/src/common/DateField.jsx
@@ -1,12 +1,14 @@
import React from 'react'
import { isDateSet } from '../utils/validations'
import { DateField as RADateField } from 'react-admin'
+import { useDateLocale } from '../i18n/useDateLocale'
export const DateField = (props) => {
const { record, source } = props
+ const locale = useDateLocale()
const value = record?.[source]
if (!isDateSet(value)) return null
- return
+ return
}
DateField.defaultProps = {
diff --git a/ui/src/common/DateField.test.jsx b/ui/src/common/DateField.test.jsx
new file mode 100644
index 000000000..47d56756b
--- /dev/null
+++ b/ui/src/common/DateField.test.jsx
@@ -0,0 +1,32 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { DateField } from './DateField'
+
+vi.mock('react-admin', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useLocale: vi.fn(),
+}))
+
+describe('', () => {
+ const record = { id: '1', updatedAt: '2026-09-17T14:30:00Z' }
+
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ vi.spyOn(navigator, 'languages', 'get').mockReturnValue([])
+ const { useLocale } = await import('react-admin')
+ vi.mocked(useLocale).mockReturnValue('de')
+ })
+
+ it('formats the date using the selected language', () => {
+ render()
+ expect(screen.getByText('17.9.2026')).toBeInTheDocument()
+ })
+
+ it('renders nothing when the date is not set', () => {
+ const { container } = render(
+ ,
+ )
+ expect(container).toBeEmptyDOMElement()
+ })
+})
diff --git a/ui/src/common/ImageUploadOverlay.jsx b/ui/src/common/ImageUploadOverlay.jsx
index 03144ddc0..a370e40fe 100644
--- a/ui/src/common/ImageUploadOverlay.jsx
+++ b/ui/src/common/ImageUploadOverlay.jsx
@@ -3,7 +3,6 @@ import { makeStyles } from '@material-ui/core/styles'
import PhotoCameraIcon from '@material-ui/icons/PhotoCamera'
import DeleteIcon from '@material-ui/icons/Delete'
import { useTranslate, useNotify, useRefresh } from 'react-admin'
-import PropTypes from 'prop-types'
import { useCallback, useRef } from 'react'
import config from '../config'
import { REST_URL } from '../consts'
@@ -37,22 +36,11 @@ const useStyles = makeStyles(() => ({
},
}))
-const defaultMessages = {
- uploaded: 'message.coverUploaded',
- uploadError: 'message.coverUploadError',
- removed: 'message.coverRemoved',
- removeError: 'message.coverRemoveError',
- uploadLabel: 'message.uploadCover',
- removeLabel: 'message.removeCover',
-}
-
export const ImageUploadOverlay = ({
entityType,
entityId,
hasUploadedImage,
onImageChange,
- canEdit,
- messages,
}) => {
const translate = useTranslate()
const notify = useNotify()
@@ -60,11 +48,8 @@ export const ImageUploadOverlay = ({
const classes = useStyles()
const fileInputRef = useRef(null)
- const msg = { ...defaultMessages, ...messages }
- // Callers pass no canEdit today; `??` (not `||`) keeps canEdit={false} from being ignored.
- const allowed =
- canEdit ??
- (config.enableArtworkUpload || localStorage.getItem('role') === 'admin')
+ const canEdit =
+ config.enableArtworkUpload || localStorage.getItem('role') === 'admin'
const handleUploadClick = useCallback((e) => {
e.stopPropagation()
@@ -87,24 +72,16 @@ export const ImageUploadOverlay = ({
headers: new Headers({}),
body: formData,
})
- notify(msg.uploaded, 'success')
- if (onImageChange) onImageChange(true)
+ notify(`message.coverUploaded`, 'success')
+ if (onImageChange) onImageChange()
refresh()
} catch (err) {
- notify(msg.uploadError, 'warning')
+ notify(`message.coverUploadError`, 'warning')
}
e.target.value = ''
},
- [
- entityType,
- entityId,
- notify,
- refresh,
- onImageChange,
- msg.uploaded,
- msg.uploadError,
- ],
+ [entityType, entityId, notify, refresh, onImageChange],
)
const handleRemoveCover = useCallback(
@@ -116,29 +93,21 @@ export const ImageUploadOverlay = ({
await httpClient(`${REST_URL}/${entityType}/${entityId}/image`, {
method: 'DELETE',
})
- notify(msg.removed, 'success')
- if (onImageChange) onImageChange(false)
+ notify(`message.coverRemoved`, 'success')
+ if (onImageChange) onImageChange()
refresh()
} catch (err) {
- notify(msg.removeError, 'warning')
+ notify(`message.coverRemoveError`, 'warning')
}
},
- [
- entityType,
- entityId,
- notify,
- refresh,
- onImageChange,
- msg.removed,
- msg.removeError,
- ],
+ [entityType, entityId, notify, refresh, onImageChange],
)
- if (!allowed) return null
+ if (!canEdit) return null
return (
-
+
{hasUploadedImage && (
-
+
)
}
-
-ImageUploadOverlay.propTypes = {
- entityType: PropTypes.string.isRequired,
- entityId: PropTypes.string,
- hasUploadedImage: PropTypes.bool,
- onImageChange: PropTypes.func,
- canEdit: PropTypes.bool,
- messages: PropTypes.shape({
- uploaded: PropTypes.string,
- uploadError: PropTypes.string,
- removed: PropTypes.string,
- removeError: PropTypes.string,
- uploadLabel: PropTypes.string,
- removeLabel: PropTypes.string,
- }),
-}
diff --git a/ui/src/common/ImageUploadOverlay.test.jsx b/ui/src/common/ImageUploadOverlay.test.jsx
deleted file mode 100644
index 904e49829..000000000
--- a/ui/src/common/ImageUploadOverlay.test.jsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import React from 'react'
-import { render, screen } from '@testing-library/react'
-import { TestContext } from 'ra-test'
-import { describe, it, expect, vi } from 'vitest'
-import { ImageUploadOverlay } from './ImageUploadOverlay'
-import config from '../config'
-
-vi.mock('react-admin', async (importOriginal) => {
- const actual = await importOriginal()
- return {
- ...actual,
- useTranslate: () => (x) => x,
- useNotify: () => vi.fn(),
- useRefresh: () => vi.fn(),
- }
-})
-
-const renderOverlay = (props) =>
- render(
-
-
- ,
- )
-
-describe('ImageUploadOverlay', () => {
- it('renders nothing when canEdit is false', () => {
- const { container } = renderOverlay({ canEdit: false })
- expect(container).toBeEmptyDOMElement()
- })
-
- it('renders when canEdit is true even if artwork upload is off', () => {
- config.enableArtworkUpload = false
- renderOverlay({ canEdit: true })
- expect(screen.getByRole('button')).toBeInTheDocument()
- })
-})
diff --git a/ui/src/common/LoveButton.jsx b/ui/src/common/LoveButton.jsx
index 492ba95b3..128b375c2 100644
--- a/ui/src/common/LoveButton.jsx
+++ b/ui/src/common/LoveButton.jsx
@@ -6,6 +6,8 @@ import IconButton from '@material-ui/core/IconButton'
import { makeStyles } from '@material-ui/core/styles'
import clsx from 'clsx'
import { useToggleLove } from './useToggleLove'
+import { useDateLocale } from '../i18n/useDateLocale'
+import { formatDateTime } from '../utils/formatters'
import { useRecordContext } from 'react-admin'
import config from '../config'
import { isDateSet } from '../utils/validations'
@@ -40,6 +42,7 @@ export const LoveButton = ({
const record = useRecordContext({ record: recordProp }) || {}
const classes = useStyles({ color, visible, loved: record.starred })
const [toggleLove, loading] = useToggleLove(resource, record)
+ const locale = useDateLocale()
const handleToggleLove = useCallback(
(e) => {
@@ -61,7 +64,7 @@ export const LoveButton = ({
className={clsx(classes.love, className)}
title={
isDateSet(record.starredAt)
- ? new Date(record.starredAt).toLocaleString()
+ ? formatDateTime(record.starredAt, locale)
: undefined
}
{...rest}
diff --git a/ui/src/common/RatingField.jsx b/ui/src/common/RatingField.jsx
index f92b0d948..f892b475a 100644
--- a/ui/src/common/RatingField.jsx
+++ b/ui/src/common/RatingField.jsx
@@ -6,6 +6,8 @@ import { isDateSet } from '../utils/validations'
import StarBorderIcon from '@material-ui/icons/StarBorder'
import clsx from 'clsx'
import { useRating } from './useRating'
+import { useDateLocale } from '../i18n/useDateLocale'
+import { formatDateTime } from '../utils/formatters'
import { useRecordContext } from 'react-admin'
const useStyles = makeStyles({
@@ -32,6 +34,7 @@ export const RatingField = ({
const record = useRecordContext(rest) || {}
const [rate, rating] = useRating(resource, record)
const classes = useStyles({ color, visible })
+ const locale = useDateLocale()
const stopPropagation = (e) => {
e.stopPropagation()
@@ -50,7 +53,7 @@ export const RatingField = ({
onClick={(e) => stopPropagation(e)}
title={
isDateSet(record.ratedAt)
- ? new Date(record.ratedAt).toLocaleString()
+ ? formatDateTime(record.ratedAt, locale)
: undefined
}
>
diff --git a/ui/src/common/ReadOnlyFields.jsx b/ui/src/common/ReadOnlyFields.jsx
new file mode 100644
index 000000000..c56643fbd
--- /dev/null
+++ b/ui/src/common/ReadOnlyFields.jsx
@@ -0,0 +1,99 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+import { FieldTitle, useRecordContext } from 'react-admin'
+import { TextField } from '@material-ui/core'
+import { makeStyles } from '@material-ui/core/styles'
+import { useDateLocale } from '../i18n/useDateLocale'
+import {
+ formatBytes,
+ formatDateTime,
+ formatDuration2,
+ formatNumber,
+} from '../utils/formatters'
+import { isDateSet } from '../utils/validations'
+
+const useStyles = makeStyles(
+ (theme) => ({
+ inputRoot: {
+ '&:hover $notchedOutline': {
+ borderColor: theme.palette.divider,
+ },
+ },
+ notchedOutline: {
+ borderColor: theme.palette.divider,
+ },
+ }),
+ { name: 'NDReadOnlyField' },
+)
+
+const identity = (v) => v
+
+// Renders a record value as a dimmed, non-editable input, so it lines up with the inputs in a form
+export const ReadOnlyTextField = ({
+ source,
+ label,
+ resource,
+ className,
+ fullWidth,
+ format = identity,
+ ...props
+}) => {
+ const classes = useStyles(props)
+ const record = useRecordContext(props)
+ const value = get(record, source)
+
+ return (
+ }
+ value={value == null ? '' : format(value)}
+ variant="outlined"
+ margin="dense"
+ fullWidth={fullWidth}
+ focused={false}
+ helperText=" "
+ InputProps={{
+ readOnly: true,
+ classes: {
+ root: classes.inputRoot,
+ notchedOutline: classes.notchedOutline,
+ },
+ }}
+ inputProps={{ tabIndex: -1 }}
+ />
+ )
+}
+
+ReadOnlyTextField.propTypes = {
+ source: PropTypes.string.isRequired,
+ label: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
+ record: PropTypes.object,
+ resource: PropTypes.string,
+ className: PropTypes.string,
+ classes: PropTypes.object,
+ fullWidth: PropTypes.bool,
+ format: PropTypes.func,
+}
+
+export const ReadOnlyDateField = (props) => {
+ const locale = useDateLocale()
+ const format = (v) => (isDateSet(v) ? formatDateTime(v, locale) : '')
+ return
+}
+
+export const ReadOnlyNumberField = (props) => {
+ const locale = useDateLocale()
+ return (
+ formatNumber(v, locale)} {...props} />
+ )
+}
+
+export const ReadOnlySizeField = (props) => (
+
+)
+
+export const ReadOnlyDurationField = (props) => (
+
+)
diff --git a/ui/src/common/ReadOnlyFields.test.jsx b/ui/src/common/ReadOnlyFields.test.jsx
new file mode 100644
index 000000000..00543a786
--- /dev/null
+++ b/ui/src/common/ReadOnlyFields.test.jsx
@@ -0,0 +1,121 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import {
+ ReadOnlyDateField,
+ ReadOnlyDurationField,
+ ReadOnlyNumberField,
+ ReadOnlySizeField,
+ ReadOnlyTextField,
+} from './ReadOnlyFields'
+
+vi.mock('react-admin', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useLocale: vi.fn(),
+}))
+
+describe('ReadOnlyFields', () => {
+ const record = {
+ id: '1',
+ client: 'NavidromeUI',
+ createdAt: '2026-09-17T14:30:00Z',
+ lastVisitedAt: '0001-01-01T00:00:00Z',
+ count: 1234567,
+ zero: 0,
+ size: 1536000,
+ duration: 3725,
+ }
+
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ vi.spyOn(navigator, 'languages', 'get').mockReturnValue([])
+ const { useLocale } = await import('react-admin')
+ vi.mocked(useLocale).mockReturnValue('de')
+ })
+
+ const renderField = (Field, props) =>
+ render()
+
+ describe('', () => {
+ it('shows the record value with the translated field label', () => {
+ renderField(ReadOnlyTextField, { source: 'client' })
+ const input = screen.getByLabelText('resources.player.fields.client')
+ expect(input).toHaveValue('NavidromeUI')
+ })
+
+ it('cannot be edited or reached with the Tab key', () => {
+ renderField(ReadOnlyTextField, { source: 'client' })
+ const input = screen.getByRole('textbox')
+ expect(input).toHaveAttribute('readonly')
+ expect(input).toHaveAttribute('tabindex', '-1')
+ })
+
+ it('shows an empty value when the record has no value', () => {
+ renderField(ReadOnlyTextField, { source: 'userName' })
+ expect(screen.getByRole('textbox')).toHaveValue('')
+ })
+
+ it('uses an explicit label when given', () => {
+ renderField(ReadOnlyTextField, { source: 'client', label: 'Custom' })
+ expect(screen.getByLabelText('Custom')).toBeInTheDocument()
+ })
+
+ it('applies a custom format', () => {
+ renderField(ReadOnlyTextField, {
+ source: 'client',
+ format: (v) => v.toUpperCase(),
+ })
+ expect(screen.getByRole('textbox')).toHaveValue('NAVIDROMEUI')
+ })
+
+ it('exposes theme-overridable class names', () => {
+ const { container } = renderField(ReadOnlyTextField, {
+ source: 'client',
+ })
+ expect(
+ container.querySelector('[class*="NDReadOnlyField-inputRoot"]'),
+ ).toBeInTheDocument()
+ expect(
+ container.querySelector('[class*="NDReadOnlyField-notchedOutline"]'),
+ ).toBeInTheDocument()
+ })
+ })
+
+ describe('', () => {
+ it('formats the date using the selected language', () => {
+ renderField(ReadOnlyDateField, { source: 'createdAt' })
+ expect(screen.getByRole('textbox').value).toMatch(/^17\.9\.2026, /)
+ })
+
+ it('shows an empty value when the date is not set', () => {
+ renderField(ReadOnlyDateField, { source: 'lastVisitedAt' })
+ expect(screen.getByRole('textbox')).toHaveValue('')
+ })
+ })
+
+ describe('', () => {
+ it('formats the number using the selected language', () => {
+ renderField(ReadOnlyNumberField, { source: 'count' })
+ expect(screen.getByRole('textbox')).toHaveValue('1.234.567')
+ })
+
+ it('shows zero', () => {
+ renderField(ReadOnlyNumberField, { source: 'zero' })
+ expect(screen.getByRole('textbox')).toHaveValue('0')
+ })
+ })
+
+ describe('', () => {
+ it('formats bytes as a human-readable size', () => {
+ renderField(ReadOnlySizeField, { source: 'size' })
+ expect(screen.getByRole('textbox')).toHaveValue('1.46 MB')
+ })
+ })
+
+ describe('', () => {
+ it('formats seconds as a human-readable duration', () => {
+ renderField(ReadOnlyDurationField, { source: 'duration' })
+ expect(screen.getByRole('textbox')).toHaveValue('1h 2m 5s')
+ })
+ })
+})
diff --git a/ui/src/common/index.js b/ui/src/common/index.js
index 0177df326..fb8f40f00 100644
--- a/ui/src/common/index.js
+++ b/ui/src/common/index.js
@@ -15,6 +15,7 @@ export * from './perPageStore'
export * from './PlayButton'
export * from './QuickFilter'
export * from './RangeField'
+export * from './ReadOnlyFields'
export * from './ShuffleAllButton'
export * from './SimpleList'
export * from './SizeField'
diff --git a/ui/src/config.js b/ui/src/config.js
index 39f0cd467..62b3cb822 100644
--- a/ui/src/config.js
+++ b/ui/src/config.js
@@ -32,12 +32,15 @@ const defaultConfig = {
listenBrainzEnabled: true,
enableExternalServices: true,
enableCoverAnimation: true,
+ pidAlbum: 'musicbrainz_albumid|albumartistid,album,albumversion,releasedate', // See consts.DefaultAlbumPID
+ pidTrack: 'musicbrainz_trackid|albumid,discnumber,tracknumber,title', // See consts.DefaultTrackPID
enableNowPlaying: true,
playbackReportIntervalMs: 60000,
devShowArtistPage: true,
devUIShowConfig: true,
devNewEventStream: false,
enableReplayGain: true,
+ enableQuickConnect: false,
defaultDownsamplingFormat: 'opus',
publicBaseUrl: '/share',
separator: '/',
diff --git a/ui/src/consts.js b/ui/src/consts.js
index 472cd4940..46c76e194 100644
--- a/ui/src/consts.js
+++ b/ui/src/consts.js
@@ -31,3 +31,9 @@ export const DEFAULT_SHARE_BITRATE = 128
export const BITRATE_CHOICES = [
32, 48, 64, 80, 96, 112, 128, 160, 192, 256, 320,
].map((b) => ({ id: b, name: b.toString() }))
+
+// 0 is a valid stored value ("no default bit rate") that BITRATE_CHOICES cannot express.
+export const TRANSCODING_BITRATE_CHOICES = [
+ { id: 0, name: 'resources.transcoding.choices.noDefaultBitRate' },
+ ...BITRATE_CHOICES,
+]
diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js
index e79beb787..7de20bcce 100644
--- a/ui/src/dataProvider/wrapperDataProvider.js
+++ b/ui/src/dataProvider/wrapperDataProvider.js
@@ -148,6 +148,12 @@ const updateUser = async (params) => {
return userResponse
}
+// ra-data-json-server merges the request body into the result; re-read so the plaintext key is never cached
+const createPlayer = async (resource, params) => {
+ const { data } = await dataProvider.create(resource, params)
+ return dataProvider.getOne(resource, { id: data.id })
+}
+
const wrapperDataProvider = {
...dataProvider,
getList: (resource, params) => {
@@ -194,6 +200,9 @@ const wrapperDataProvider = {
return createUser(params)
}
const [r, p] = mapResource(resource, params)
+ if (resource === 'player') {
+ return createPlayer(r, p)
+ }
return dataProvider.create(r, p)
},
delete: (resource, params) => {
@@ -218,6 +227,15 @@ const wrapperDataProvider = {
({ json }) => ({ data: json }),
)
},
+ lookupQuickConnect: (code) =>
+ httpClient(
+ `${REST_URL}/quickconnect?code=${encodeURIComponent(code)}`,
+ ).then(({ json }) => ({ data: json })),
+ authorizeQuickConnect: (code) =>
+ httpClient(`${REST_URL}/quickconnect/authorize`, {
+ method: 'POST',
+ body: JSON.stringify({ code }),
+ }).then(({ json }) => ({ data: json })),
inspect: (songId) => {
return httpClient(`${REST_URL}/inspect?id=${songId}`).then(({ json }) => ({
data: json,
diff --git a/ui/src/dataProvider/wrapperDataProvider.test.js b/ui/src/dataProvider/wrapperDataProvider.test.js
index 4225a5a54..1c33aad5d 100644
--- a/ui/src/dataProvider/wrapperDataProvider.test.js
+++ b/ui/src/dataProvider/wrapperDataProvider.test.js
@@ -88,6 +88,21 @@ describe('wrapperDataProvider', () => {
})
})
+ describe('create player', () => {
+ it('returns the server record, never the plaintext API key', async () => {
+ const data = { name: 'Phone', apiKey: 'nds_0123456789abcdefghijkl' }
+ const saved = { id: 'p1', name: 'Phone', hasApiKey: true, userId: 'u1' }
+ mockProvider.create.mockResolvedValue({ data: { ...data, id: 'p1' } })
+ mockProvider.getOne.mockResolvedValue({ data: saved })
+
+ const result = await wrapperDataProvider.create('player', { data })
+
+ expect(mockProvider.create).toHaveBeenCalledWith('player', { data })
+ expect(mockProvider.getOne).toHaveBeenCalledWith('player', { id: 'p1' })
+ expect(result.data).toEqual(saved)
+ })
+ })
+
describe('refreshMetadata', () => {
it('posts to the album metadata refresh endpoint', () => {
mockHttpClient.mockResolvedValue({ json: {} })
diff --git a/ui/src/dialogs/QuickConnectDialog.jsx b/ui/src/dialogs/QuickConnectDialog.jsx
new file mode 100644
index 000000000..cde7e9eaf
--- /dev/null
+++ b/ui/src/dialogs/QuickConnectDialog.jsx
@@ -0,0 +1,128 @@
+import { useState } from 'react'
+import PropTypes from 'prop-types'
+import { useDataProvider, useNotify, useTranslate } from 'react-admin'
+import {
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ DialogTitle,
+ TextField,
+} from '@material-ui/core'
+
+export const QuickConnectDialog = ({ open, onClose }) => {
+ const translate = useTranslate()
+ const notify = useNotify()
+ const dataProvider = useDataProvider()
+ const [code, setCode] = useState('')
+ const [pending, setPending] = useState(null)
+ const [loading, setLoading] = useState(false)
+
+ const handleClose = () => {
+ setCode('')
+ setPending(null)
+ onClose()
+ }
+
+ const handleError = (error) => {
+ setPending(null)
+ const invalid = error?.status === 404 || error?.status === 409
+ notify(
+ invalid ? 'message.quickConnectInvalidCode' : 'message.quickConnectError',
+ 'warning',
+ )
+ }
+
+ const run = (request, onSuccess) => {
+ setLoading(true)
+ request
+ .then(({ data }) => onSuccess(data))
+ .catch(handleError)
+ .finally(() => setLoading(false))
+ }
+
+ const lookup = (event) => {
+ event.preventDefault()
+ run(dataProvider.lookupQuickConnect(code), setPending)
+ }
+
+ const approve = () =>
+ run(dataProvider.authorizeQuickConnect(code), (data) => {
+ notify('message.quickConnectApproved', 'success', {
+ app: data.appName,
+ device: data.deviceName,
+ })
+ handleClose()
+ })
+
+ return (
+
+ )
+}
+
+QuickConnectDialog.propTypes = {
+ open: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+}
diff --git a/ui/src/dialogs/QuickConnectDialog.test.jsx b/ui/src/dialogs/QuickConnectDialog.test.jsx
new file mode 100644
index 000000000..5dda1cbdf
--- /dev/null
+++ b/ui/src/dialogs/QuickConnectDialog.test.jsx
@@ -0,0 +1,103 @@
+import * as React from 'react'
+import { TestContext } from 'ra-test'
+import { DataProviderContext } from 'react-admin'
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react'
+import { describe, afterEach, it, expect, vi } from 'vitest'
+import { QuickConnectDialog } from './QuickConnectDialog'
+
+const finamp = { appName: 'Finamp', appVersion: '1.0.0', deviceName: 'Pixel 7' }
+
+const renderDialog = (dataProvider, onClose = vi.fn()) => {
+ render(
+
+
+
+
+ ,
+ )
+ return onClose
+}
+
+const enterCode = (code) => {
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: code } })
+ fireEvent.click(screen.getByText('menu.quickConnect.continue'))
+}
+
+describe('QuickConnectDialog', () => {
+ afterEach(cleanup)
+
+ it('shows the device before approving the code', async () => {
+ const dataProvider = {
+ lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }),
+ authorizeQuickConnect: vi.fn().mockResolvedValue({ data: finamp }),
+ }
+ const onClose = renderDialog(dataProvider)
+
+ enterCode('123 456')
+ await screen.findByText('menu.quickConnect.approve')
+ expect(dataProvider.lookupQuickConnect.mock.calls[0][0]).toBe('123 456')
+ expect(dataProvider.authorizeQuickConnect).not.toHaveBeenCalled()
+
+ fireEvent.click(screen.getByText('menu.quickConnect.approve'))
+ await waitFor(() => expect(onClose).toHaveBeenCalled())
+ expect(dataProvider.authorizeQuickConnect.mock.calls[0][0]).toBe('123 456')
+ })
+
+ it('goes back to the code input', async () => {
+ const dataProvider = {
+ lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }),
+ authorizeQuickConnect: vi.fn(),
+ }
+ renderDialog(dataProvider)
+
+ enterCode('123456')
+ await screen.findByText('menu.quickConnect.approve')
+ fireEvent.click(screen.getByText('ra.action.back'))
+
+ expect(screen.getByRole('textbox')).toHaveValue('123456')
+ expect(dataProvider.authorizeQuickConnect).not.toHaveBeenCalled()
+ })
+
+ it('stays on the code input when the code is unknown', async () => {
+ const dataProvider = {
+ lookupQuickConnect: vi.fn().mockRejectedValue({ status: 404 }),
+ authorizeQuickConnect: vi.fn(),
+ }
+ const onClose = renderDialog(dataProvider)
+
+ enterCode('000000')
+ await waitFor(() =>
+ expect(dataProvider.lookupQuickConnect).toHaveBeenCalled(),
+ )
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ expect(screen.queryByText('menu.quickConnect.approve')).toBeNull()
+ expect(onClose).not.toHaveBeenCalled()
+ })
+
+ it('returns to the code input when approval fails', async () => {
+ const dataProvider = {
+ lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }),
+ authorizeQuickConnect: vi.fn().mockRejectedValue({ status: 409 }),
+ }
+ const onClose = renderDialog(dataProvider)
+
+ enterCode('123456')
+ fireEvent.click(await screen.findByText('menu.quickConnect.approve'))
+
+ await screen.findByRole('textbox')
+ expect(onClose).not.toHaveBeenCalled()
+ })
+
+ it('disables Continue until a code is typed', () => {
+ renderDialog({ lookupQuickConnect: vi.fn() })
+ expect(
+ screen.getByText('menu.quickConnect.continue').closest('button'),
+ ).toBeDisabled()
+ })
+})
diff --git a/ui/src/dialogs/index.js b/ui/src/dialogs/index.js
index 86586aef0..3234e4685 100644
--- a/ui/src/dialogs/index.js
+++ b/ui/src/dialogs/index.js
@@ -2,4 +2,5 @@ export * from './AboutDialog'
export * from './SelectPlaylistInput'
export * from './ListenBrainzTokenDialog'
export * from './SaveQueueDialog'
+export * from './QuickConnectDialog'
export * from './Dialogs'
diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json
index 6c23f393b..f694ea75f 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -83,7 +83,8 @@
"grouping": "Grouping",
"media": "Media",
"mood": "Mood",
- "missing": "Missing"
+ "missing": "Missing",
+ "played": "Played"
},
"actions": {
"playAll": "Play",
@@ -182,6 +183,7 @@
},
"player": {
"name": "Player |||| Players",
+ "menuName": "Players & API keys",
"fields": {
"name": "Name",
"transcodingId": "Transcoding",
@@ -190,7 +192,29 @@
"userName": "Username",
"lastSeen": "Last Seen At",
"reportRealPath": "Report Real Path",
- "scrobbleEnabled": "Send Scrobbles to external services"
+ "scrobbleEnabled": "Send Scrobbles to external services",
+ "hasApiKey": "API Key"
+ },
+ "actions": {
+ "generateApiKey": "Generate API key",
+ "regenerateApiKey": "Regenerate",
+ "revokeApiKey": "Revoke",
+ "copyApiKey": "Copy"
+ },
+ "message": {
+ "apiKeyActive": "This player has an API key. Use it in your app as the API key, or as the password if the app does not use token authentication.",
+ "apiKeyNone": "No API key. Generate one to connect an app to this player.",
+ "apiKeyNoneOther": "No API key.",
+ "apiKeyPending": "Copy this key now. It is saved when you click Save and will not be shown again.",
+ "apiKeyRevokePending": "The API key will be removed when you save.",
+ "deleteWithKeyTitle": "Delete player",
+ "deleteWithKeyContent": "This player has an API key. Apps using it will stop working."
+ },
+ "notifications": {
+ "apiKeyCopied": "API key copied to clipboard"
+ },
+ "validation": {
+ "apiKeyFormat": "Invalid API key format"
}
},
"transcoding": {
@@ -200,6 +224,9 @@
"targetFormat": "Target Format",
"defaultBitRate": "Default Bit Rate",
"command": "Command"
+ },
+ "choices": {
+ "noDefaultBitRate": "None"
}
},
"playlist": {
@@ -304,11 +331,22 @@
"totalDuration": "Duration",
"defaultNewUsers": "Default for New Users",
"createdAt": "Created",
- "updatedAt": "Updated"
+ "updatedAt": "Updated",
+ "pidAlbum": "Album grouping",
+ "pidTrack": "Track identity"
},
"sections": {
"basic": "Basic Information",
- "statistics": "Statistics"
+ "statistics": "Statistics",
+ "pid": "Persistent IDs"
+ },
+ "pid": {
+ "global": "Use global setting (%{value})",
+ "folder": "Folder (one album per folder)",
+ "custom": "Custom",
+ "spec": "PID spec",
+ "help": "Tags and attributes that identify an item. See the documentation for the syntax:",
+ "docs": "Persistent IDs"
},
"actions": {
"scan": "Scan Library",
@@ -338,7 +376,9 @@
"messages": {
"deleteConfirm": "Are you sure you want to delete this library? This will remove all associated data and user access.",
"scanInProgress": "Scan in progress...",
- "noLibrariesAssigned": "No libraries assigned to this user"
+ "noLibrariesAssigned": "No libraries assigned to this user",
+ "pidChangeTitle": "Change persistent IDs?",
+ "pidChangeConfirm": "This regroups albums and tracks in this library. A full rescan of this library starts now. Track stars, ratings and play counts are kept. Album stars and ratings move to the new albums where an old album maps to a new one."
}
},
"plugin": {
@@ -571,12 +611,6 @@
"coverRemoved": "Cover art removed",
"coverUploadError": "Error uploading cover art",
"coverRemoveError": "Error removing cover art",
- "uploadAvatar": "Upload avatar",
- "removeAvatar": "Remove avatar",
- "avatarUploaded": "Avatar updated",
- "avatarUploadError": "Error updating avatar",
- "avatarRemoved": "Avatar removed",
- "avatarRemoveError": "Error removing avatar",
"metadataRefreshStarted": "Refreshing metadata in the background",
"note": "NOTE",
"transcodingDisabled": "Changing the transcoding configuration through the web interface is disabled for security reasons. If you would like to change (edit or add) transcoding options, restart the server with the %{config} configuration option.",
@@ -594,6 +628,9 @@
"remove_all_missing_content": "Are you sure you want to remove all missing files from the database? This will permanently remove any references to them, including their play counts and ratings.",
"notifications_blocked": "You have blocked Notifications for this site in your browser's settings",
"notifications_not_available": "This browser does not support desktop notifications or you are not accessing Navidrome over https",
+ "quickConnectApproved": "%{app} on %{device} is now signed in",
+ "quickConnectInvalidCode": "Invalid or expired code",
+ "quickConnectError": "Could not approve the code",
"lastfmLinkSuccess": "Last.fm successfully linked and scrobbling enabled",
"lastfmLinkFailure": "Last.fm could not be linked",
"lastfmUnlinkSuccess": "Last.fm unlinked and scrobbling disabled",
@@ -625,6 +662,14 @@
"none": "None"
},
"settings": "Settings",
+ "quickConnect": {
+ "name": "Quick Connect",
+ "code": "Code",
+ "help": "Enter the code shown by a Jellyfin app to sign it in to your account",
+ "confirm": "Sign in %{app} %{version} on %{device} to your account?",
+ "continue": "Continue",
+ "approve": "Approve"
+ },
"version": "Version",
"theme": "Theme",
"personal": {
diff --git a/ui/src/i18n/useDateLocale.js b/ui/src/i18n/useDateLocale.js
new file mode 100644
index 000000000..c9298a473
--- /dev/null
+++ b/ui/src/i18n/useDateLocale.js
@@ -0,0 +1,14 @@
+import { useLocale } from 'react-admin'
+
+// Our language codes are mostly region-less ("en"), and Intl reads a bare "en"
+// as en-US. Borrow the region from the browser when it speaks the same language.
+const resolveDateLocale = (locale, browserLocales = []) => {
+ if (!locale || locale.includes('-')) return locale
+ const base = locale.toLowerCase()
+ return (
+ browserLocales.find((l) => l.toLowerCase().split('-')[0] === base) || locale
+ )
+}
+
+export const useDateLocale = () =>
+ resolveDateLocale(useLocale(), navigator.languages)
diff --git a/ui/src/i18n/useDateLocale.test.js b/ui/src/i18n/useDateLocale.test.js
new file mode 100644
index 000000000..c0ea5ab40
--- /dev/null
+++ b/ui/src/i18n/useDateLocale.test.js
@@ -0,0 +1,45 @@
+import { renderHook } from '@testing-library/react-hooks'
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { useDateLocale } from './useDateLocale'
+
+vi.mock('react-admin', () => ({
+ useLocale: vi.fn(),
+}))
+
+describe('useDateLocale', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ const renderWith = async (locale, browserLocales) => {
+ const { useLocale } = await import('react-admin')
+ vi.mocked(useLocale).mockReturnValue(locale)
+ vi.spyOn(navigator, 'languages', 'get').mockReturnValue(browserLocales)
+ return renderHook(() => useDateLocale()).result
+ }
+
+ it('adds the region from the browser when the language has none', async () => {
+ const result = await renderWith('en', ['en-GB', 'fr-FR'])
+ expect(result.current).toEqual('en-GB')
+ })
+
+ it('falls back to the language when no browser entry matches', async () => {
+ const result = await renderWith('de', ['en-US', 'fr-FR'])
+ expect(result.current).toEqual('de')
+ })
+
+ it('keeps a language that already carries a region or script', async () => {
+ expect((await renderWith('pt-br', ['pt-PT'])).current).toEqual('pt-br')
+ expect((await renderWith('zh-Hans', ['zh-TW'])).current).toEqual('zh-Hans')
+ })
+
+ it('matches the browser language case-insensitively', async () => {
+ const result = await renderWith('pt', ['PT-PT'])
+ expect(result.current).toEqual('PT-PT')
+ })
+
+ it('returns undefined when there is no language', async () => {
+ const result = await renderWith(undefined, ['en-GB'])
+ expect(result.current).toBeUndefined()
+ })
+})
diff --git a/ui/src/layout/AppBar.jsx b/ui/src/layout/AppBar.jsx
index 561701dce..460d33bb9 100644
--- a/ui/src/layout/AppBar.jsx
+++ b/ui/src/layout/AppBar.jsx
@@ -6,12 +6,17 @@ import {
usePermissions,
getResources,
} from 'react-admin'
-import { MdInfo, MdPerson, MdSupervisorAccount } from 'react-icons/md'
+import {
+ MdInfo,
+ MdPerson,
+ MdPhonelink,
+ MdSupervisorAccount,
+} from 'react-icons/md'
import { useSelector } from 'react-redux'
import { makeStyles, MenuItem, ListItemIcon, Divider } from '@material-ui/core'
import ViewListIcon from '@material-ui/icons/ViewList'
import { Dialogs } from '../dialogs/Dialogs'
-import { AboutDialog } from '../dialogs'
+import { AboutDialog, QuickConnectDialog } from '../dialogs'
import PersonalMenu from './PersonalMenu'
import ActivityPanel from './ActivityPanel'
import NowPlayingPanel from './NowPlayingPanel'
@@ -33,33 +38,34 @@ const useStyles = makeStyles(
},
)
-const AboutMenuItem = forwardRef(({ onClick, ...rest }, ref) => {
- const classes = useStyles(rest)
- const translate = useTranslate()
- const [open, setOpen] = React.useState(false)
+const DialogMenuItem = forwardRef(
+ ({ onClick, label, icon, dialog, ...rest }, ref) => {
+ const classes = useStyles(rest)
+ const [open, setOpen] = React.useState(false)
- const handleOpen = () => {
- setOpen(true)
- }
- const handleClose = () => {
- onClick && onClick()
- setOpen(false)
- }
- const label = translate('menu.about')
- return (
- <>
-
-
- >
- )
-})
+ const handleClose = () => {
+ onClick && onClick()
+ setOpen(false)
+ }
+ return (
+ <>
+
+ {createElement(dialog, { onClose: handleClose, open })}
+ >
+ )
+ },
+)
-AboutMenuItem.displayName = 'AboutMenuItem'
+DialogMenuItem.displayName = 'DialogMenuItem'
const settingsResources = (resource) =>
resource.name !== 'user' &&
@@ -96,9 +102,11 @@ const CustomUserMenu = ({ onClick, ...rest }) => {
}
const renderSettingsMenuItemLink = (resource, id) => {
- const label = translate(`resources.${resource.name}.name`, {
- smart_count: id ? 1 : 2,
- })
+ const label = resource.options.label
+ ? translate(resource.options.label)
+ : translate(`resources.${resource.name}.name`, {
+ smart_count: id ? 1 : 2,
+ })
const link = id ? `/${resource.name}/${id}` : `/${resource.name}`
return (
{
{config.devActivityPanel && permissions === 'admin' && }
+ {config.enableQuickConnect && (
+
+ )}
{renderUserMenuItemLink()}
{resources
.filter(settingsResources)
.map((r) => renderSettingsMenuItemLink(r))}
-
+
>
diff --git a/ui/src/layout/AppBar.test.jsx b/ui/src/layout/AppBar.test.jsx
index f39dd75cb..cc5ef781d 100644
--- a/ui/src/layout/AppBar.test.jsx
+++ b/ui/src/layout/AppBar.test.jsx
@@ -9,11 +9,14 @@ import config from '../config'
let store
+const mocks = vi.hoisted(() => ({ resources: [] }))
+
vi.mock('react-admin', () => ({
AppBar: ({ userMenu }) => {userMenu}
,
+ MenuItemLink: ({ primaryText }) => {primaryText}
,
useTranslate: () => (x) => x,
usePermissions: () => ({ permissions: 'admin' }),
- getResources: () => [],
+ getResources: () => mocks.resources,
}))
vi.mock('./NowPlayingPanel', () => ({
@@ -33,12 +36,15 @@ vi.mock('../dialogs/Dialogs', () => ({
}))
vi.mock('../dialogs', () => ({
AboutDialog: () => ,
+ QuickConnectDialog: () => ,
}))
describe('', () => {
beforeEach(() => {
config.devActivityPanel = true
config.enableNowPlaying = true
+ config.enableQuickConnect = false
+ mocks.resources = []
store = createStore(combineReducers({ activity: activityReducer }), {
activity: { nowPlayingCount: 0 },
})
@@ -62,4 +68,42 @@ describe('', () => {
)
expect(screen.queryByTestId('now-playing-panel')).toBeNull()
})
+
+ it('shows the Quick Connect menu item when enabled', () => {
+ config.enableQuickConnect = true
+ render(
+
+
+ ,
+ )
+ expect(screen.queryAllByText('menu.quickConnect.name')).not.toHaveLength(0)
+ })
+
+ it('hides the Quick Connect menu item when disabled', () => {
+ render(
+
+
+ ,
+ )
+ expect(screen.queryAllByText('menu.quickConnect.name')).toHaveLength(0)
+ expect(screen.queryAllByText('menu.about')).not.toHaveLength(0)
+ })
+
+ it('uses the resource label for settings items when set', () => {
+ mocks.resources = [
+ {
+ name: 'player',
+ hasList: true,
+ options: { subMenu: 'settings', label: 'resources.player.menuName' },
+ },
+ { name: 'transcoding', hasList: true, options: { subMenu: 'settings' } },
+ ]
+ render(
+
+
+ ,
+ )
+ expect(screen.getByText('resources.player.menuName')).toBeInTheDocument()
+ expect(screen.getByText('resources.transcoding.name')).toBeInTheDocument()
+ })
})
diff --git a/ui/src/layout/Notification.jsx b/ui/src/layout/Notification.jsx
index 001d3fc01..ff641ec22 100644
--- a/ui/src/layout/Notification.jsx
+++ b/ui/src/layout/Notification.jsx
@@ -1,11 +1,26 @@
import React from 'react'
import { Notification as RANotification } from 'react-admin'
+import { makeStyles } from '@material-ui/core/styles'
-const Notification = (props) => (
-
+// RA's primary.light Undo is unreadable on the light snackbar of dark themes
+const useStyles = makeStyles(
+ {
+ undo: {
+ color: 'inherit',
+ },
+ },
+ { name: 'NDNotification' },
)
+const Notification = (props) => {
+ const classes = useStyles()
+ return (
+
+ )
+}
+
export default Notification
diff --git a/ui/src/layout/UserMenu.jsx b/ui/src/layout/UserMenu.jsx
index 1b465ed35..e33185578 100644
--- a/ui/src/layout/UserMenu.jsx
+++ b/ui/src/layout/UserMenu.jsx
@@ -7,7 +7,7 @@ import {
useState,
} from 'react'
import PropTypes from 'prop-types'
-import { useTranslate, useGetIdentity, useVersion } from 'react-admin'
+import { useTranslate, useGetIdentity } from 'react-admin'
import {
Tooltip,
IconButton,
@@ -25,7 +25,6 @@ import config from '../config'
import authProvider from '../authProvider'
import { startEventStream } from '../eventStream'
import { useDispatch } from 'react-redux'
-import subsonic from '../subsonic'
const useStyles = makeStyles((theme) => ({
user: {},
@@ -52,25 +51,11 @@ const UserMenu = (props) => {
const [anchorEl, setAnchorEl] = useState(null)
const translate = useTranslate()
const { loaded, identity } = useGetIdentity()
- const version = useVersion()
- const [avatarTag, setAvatarTag] = useState(null)
const classes = useStyles(props)
const dispatch = useDispatch()
const { children, label, icon, logout } = props
- // The tag is only set when an avatar was uploaded, and it changes mid-session on upload or
- // removal, so the identity is re-read on every refresh instead of trusting the login snapshot.
- useEffect(() => {
- authProvider.getIdentity().then((user) => setAvatarTag(user?.avatarTag))
- }, [version])
-
- // identity.id is the username (see authProvider.getIdentity)
- const avatarUrl =
- avatarTag && identity?.id
- ? `${subsonic.getAvatarUrl(identity.id)}&_=${avatarTag}`
- : identity?.avatar
-
useEffect(() => {
if (config.devActivityPanel) {
authProvider
@@ -96,10 +81,10 @@ const UserMenu = (props) => {
aria-haspopup={true}
onClick={handleMenu}
>
- {loaded && avatarUrl ? (
+ {loaded && identity.avatar ? (
) : (
diff --git a/ui/src/layout/UserMenu.test.jsx b/ui/src/layout/UserMenu.test.jsx
deleted file mode 100644
index 4a21b94ca..000000000
--- a/ui/src/layout/UserMenu.test.jsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import React from 'react'
-import { render, screen } from '@testing-library/react'
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-import UserMenu from './UserMenu'
-
-vi.mock('../subsonic', () => ({
- default: {
- getAvatarUrl: vi.fn((username) => `/app/rest/getAvatar.view?u=${username}`),
- },
-}))
-
-vi.mock('react-redux', () => ({
- useDispatch: () => vi.fn(),
-}))
-
-let mockIdentity
-let mockVersion
-vi.mock('react-admin', () => ({
- useTranslate: () => (x) => x,
- useGetIdentity: () => ({ loaded: true, identity: mockIdentity }),
- useVersion: () => mockVersion,
-}))
-
-const renderUserMenu = (identity) => {
- mockIdentity = identity
- return render(Logout } />)
-}
-
-describe('', () => {
- beforeEach(() => {
- localStorage.clear()
- mockVersion = 1
- })
-
- it('uses the uploaded avatar when there is an avatar tag', async () => {
- localStorage.setItem('avatarTag', 'abc123')
- renderUserMenu({ id: 'deluan', fullName: 'Deluan' })
- expect(await screen.findByRole('img')).toHaveAttribute(
- 'src',
- expect.stringContaining('getAvatar'),
- )
- })
-
- it('falls back to the generic icon with no avatar and no gravatar', () => {
- renderUserMenu({ id: 'deluan', fullName: 'Deluan' })
- expect(screen.queryByRole('img')).not.toBeInTheDocument()
- })
-
- it('uses the gravatar url when there is no avatar tag', () => {
- renderUserMenu({
- id: 'deluan',
- fullName: 'Deluan',
- avatar: 'https://gravatar/u1',
- })
- expect(screen.getByRole('img')).toHaveAttribute(
- 'src',
- 'https://gravatar/u1',
- )
- })
-
- it('picks up an avatar uploaded during the session', async () => {
- const { rerender } = renderUserMenu({ id: 'deluan', fullName: 'Deluan' })
- expect(screen.queryByRole('img')).not.toBeInTheDocument()
-
- localStorage.setItem('avatarTag', 'newtag')
- mockVersion = 2
- rerender(Logout } />)
-
- expect(await screen.findByRole('img')).toHaveAttribute(
- 'src',
- expect.stringContaining('newtag'),
- )
- })
-
- it('goes back to the generic icon when the avatar is removed during the session', async () => {
- localStorage.setItem('avatarTag', 'abc123')
- const { rerender } = renderUserMenu({ id: 'deluan', fullName: 'Deluan' })
- expect(await screen.findByRole('img')).toBeInTheDocument()
-
- localStorage.removeItem('avatarTag')
- mockVersion = 2
- rerender(
Logout} />)
-
- await vi.waitFor(() =>
- expect(screen.queryByRole('img')).not.toBeInTheDocument(),
- )
- })
-})
diff --git a/ui/src/library/LibraryCreate.jsx b/ui/src/library/LibraryCreate.jsx
index 0e69964b6..8166bb2f3 100644
--- a/ui/src/library/LibraryCreate.jsx
+++ b/ui/src/library/LibraryCreate.jsx
@@ -1,4 +1,5 @@
import React, { useCallback } from 'react'
+import PropTypes from 'prop-types'
import {
Create,
SimpleForm,
@@ -10,7 +11,34 @@ import {
useNotify,
useRedirect,
} from 'react-admin'
+import { Typography } from '@material-ui/core'
+import { makeStyles } from '@material-ui/core/styles'
import { Title } from '../common'
+import { PIDInputs } from './PIDInput'
+
+const useStyles = makeStyles((theme) => ({
+ spaced: { marginTop: theme.spacing(3) },
+}))
+
+// SimpleForm passes form props (variant, record, ...) to its children, so Typography can't be used directly
+const SectionTitle = ({ label, spaced }) => {
+ const translate = useTranslate()
+ const classes = useStyles()
+ return (
+
+ {translate(label)}
+
+ )
+}
+
+SectionTitle.propTypes = {
+ label: PropTypes.string.isRequired,
+ spaced: PropTypes.bool,
+}
const LibraryCreate = (props) => {
const translate = useTranslate()
@@ -73,9 +101,12 @@ const LibraryCreate = (props) => {
return (
} {...props}>
+
+
+
)
diff --git a/ui/src/library/LibraryEdit.jsx b/ui/src/library/LibraryEdit.jsx
index 7e89c892c..c42c7ac4b 100644
--- a/ui/src/library/LibraryEdit.jsx
+++ b/ui/src/library/LibraryEdit.jsx
@@ -1,12 +1,13 @@
-import React, { useCallback } from 'react'
+import React, { useCallback, useState } from 'react'
+import PropTypes from 'prop-types'
import {
Edit,
FormWithRedirect,
TextInput,
BooleanInput,
+ Confirm,
required,
SaveButton,
- DateField,
useTranslate,
useMutation,
useNotify,
@@ -16,8 +17,16 @@ import {
import { Typography, Box } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import DeleteLibraryButton from './DeleteLibraryButton'
-import { Title } from '../common'
-import { formatBytes, formatDuration2, formatNumber } from '../utils/index.js'
+import {
+ ReadOnlyDateField,
+ ReadOnlyDurationField,
+ ReadOnlyNumberField,
+ ReadOnlySizeField,
+ Title,
+} from '../common'
+import config from '../config'
+import { PIDInputs } from './PIDInput'
+import { pidConfigChanged } from './pidPresets'
const useStyles = makeStyles({
toolbar: {
@@ -26,6 +35,8 @@ const useStyles = makeStyles({
},
})
+const readOnlyProps = { resource: 'library', fullWidth: true }
+
const LibraryTitle = ({ record }) => {
const translate = useTranslate()
const resourceName = translate('resources.library.name', { smart_count: 1 })
@@ -47,8 +58,131 @@ const CustomToolbar = ({ showDelete, ...props }) => (
)
-const LibraryEdit = (props) => {
+export const LibraryEditForm = ({ formProps, canEditPath, canDelete }) => {
const translate = useTranslate()
+ const [confirmOpen, setConfirmOpen] = useState(false)
+
+ // Every submit path (Save button and Enter key) goes through here, so a PID change always asks first
+ const submit = () => {
+ if (
+ pidConfigChanged(
+ formProps.form.getState().values,
+ formProps.record,
+ config,
+ )
+ ) {
+ setConfirmOpen(true)
+ return
+ }
+ formProps.handleSubmit()
+ }
+
+ const handleConfirm = () => {
+ setConfirmOpen(false)
+ formProps.handleSubmit()
+ }
+
+ return (
+
+ )
+}
+
+LibraryEditForm.propTypes = {
+ formProps: PropTypes.object.isRequired,
+ canEditPath: PropTypes.bool,
+ canDelete: PropTypes.bool,
+}
+
+const LibraryEdit = (props) => {
const [mutate] = useMutation()
const notify = useNotify()
const redirect = useRedirect()
@@ -87,183 +221,11 @@ const LibraryEdit = (props) => {
{...props}
save={save}
render={(formProps) => (
-
+
)}
/>
diff --git a/ui/src/library/LibraryEdit.test.jsx b/ui/src/library/LibraryEdit.test.jsx
new file mode 100644
index 000000000..926adc839
--- /dev/null
+++ b/ui/src/library/LibraryEdit.test.jsx
@@ -0,0 +1,125 @@
+import * as React from 'react'
+import { TestContext } from 'ra-test'
+import {
+ FormWithRedirect,
+ RecordContextProvider,
+ SaveContextProvider,
+} from 'react-admin'
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from '@testing-library/react'
+import { describe, it, expect, vi, afterEach } from 'vitest'
+import { LibraryEditForm } from './LibraryEdit'
+import config from '../config'
+
+const record = {
+ id: '2',
+ name: 'Jazz',
+ path: '/music/jazz',
+ pidAlbum: '',
+ pidTrack: '',
+}
+
+// Edit provides a save context in the app. SaveButton only reads these setters from it
+const saveContext = {
+ save: vi.fn(),
+ setOnSuccess: vi.fn(),
+ setOnFailure: vi.fn(),
+ setTransform: vi.fn(),
+}
+
+const renderForm = (save) =>
+ render(
+
+
+
+ (
+
+ )}
+ />
+
+
+ ,
+ )
+
+const chooseAlbumGrouping = (optionText) => {
+ fireEvent.mouseDown(
+ screen.getByLabelText('resources.library.fields.pidAlbum'),
+ )
+ fireEvent.click(within(screen.getByRole('listbox')).getByText(optionText))
+}
+
+const dialogTitle = 'resources.library.messages.pidChangeTitle'
+
+describe('LibraryEditForm', () => {
+ afterEach(cleanup)
+
+ it('saves directly when the PID config did not change', async () => {
+ const save = vi.fn()
+ renderForm(save)
+ fireEvent.change(screen.getByLabelText(/resources.library.fields.name/), {
+ target: { value: 'Jazz Renamed' },
+ })
+ fireEvent.click(screen.getByText('ra.action.save'))
+ await waitFor(() => expect(save).toHaveBeenCalled())
+ expect(screen.queryByText(dialogTitle)).not.toBeInTheDocument()
+ })
+
+ it('asks before saving a PID change, and Cancel keeps the edits', async () => {
+ const save = vi.fn()
+ renderForm(save)
+ chooseAlbumGrouping('resources.library.pid.folder')
+ fireEvent.click(screen.getByText('ra.action.save'))
+
+ expect(await screen.findByText(dialogTitle)).toBeInTheDocument()
+ expect(save).not.toHaveBeenCalled()
+
+ fireEvent.click(screen.getByText('ra.action.cancel'))
+ await waitFor(() =>
+ expect(screen.queryByText(dialogTitle)).not.toBeInTheDocument(),
+ )
+ expect(save).not.toHaveBeenCalled()
+ expect(screen.getByText('resources.library.pid.folder')).toBeInTheDocument()
+ })
+
+ it('saves the PID change after Confirm', async () => {
+ const save = vi.fn()
+ renderForm(save)
+ chooseAlbumGrouping('resources.library.pid.folder')
+ fireEvent.click(screen.getByText('ra.action.save'))
+ fireEvent.click(await screen.findByText('ra.action.confirm'))
+
+ await waitFor(() => expect(save).toHaveBeenCalled())
+ expect(save.mock.calls[0][0]).toMatchObject({ pidAlbum: 'folder' })
+ })
+
+ it('pre-fills a Custom spec with the global spec', () => {
+ renderForm(vi.fn())
+ chooseAlbumGrouping('resources.library.pid.custom')
+ expect(screen.getByLabelText(/resources.library.pid.spec/)).toHaveValue(
+ config.pidAlbum,
+ )
+ })
+
+ it('asks before saving when the form is submitted with Enter', async () => {
+ const save = vi.fn()
+ const { container } = renderForm(save)
+ chooseAlbumGrouping('resources.library.pid.folder')
+ fireEvent.submit(container.querySelector('form'))
+
+ expect(await screen.findByText(dialogTitle)).toBeInTheDocument()
+ expect(save).not.toHaveBeenCalled()
+ })
+})
diff --git a/ui/src/library/PIDInput.jsx b/ui/src/library/PIDInput.jsx
new file mode 100644
index 000000000..6481dc392
--- /dev/null
+++ b/ui/src/library/PIDInput.jsx
@@ -0,0 +1,114 @@
+import React, { useState } from 'react'
+import PropTypes from 'prop-types'
+import { TextInput, required, useTranslate } from 'react-admin'
+import { useField } from 'react-final-form'
+import { FormHelperText, Link, MenuItem, TextField } from '@material-ui/core'
+import { makeStyles } from '@material-ui/core/styles'
+import {
+ PID_CUSTOM,
+ PID_FOLDER,
+ PID_GLOBAL,
+ pidModeFromValue,
+ pidValueForMode,
+} from './pidPresets'
+import config from '../config'
+import { docsUrl } from '../utils'
+
+const PID_DOCS_URL = docsUrl('/docs/usage/pids/')
+
+const useStyles = makeStyles((theme) => ({
+ help: { marginBottom: theme.spacing(1) },
+}))
+
+// PIDInput edits a library PID override: use the global setting, a preset, or a custom spec
+export const PIDInput = ({ source, label, globalValue, allowFolder }) => {
+ const translate = useTranslate()
+ const classes = useStyles()
+ const { input } = useField(source)
+ // Local state, so choosing Custom shows the text box before anything is typed
+ const [mode, setMode] = useState(() =>
+ pidModeFromValue(input.value, allowFolder),
+ )
+
+ const choices = [
+ {
+ id: PID_GLOBAL,
+ name: translate('resources.library.pid.global', { value: globalValue }),
+ },
+ ...(allowFolder
+ ? [{ id: PID_FOLDER, name: translate('resources.library.pid.folder') }]
+ : []),
+ { id: PID_CUSTOM, name: translate('resources.library.pid.custom') },
+ ]
+
+ const handleModeChange = (event) => {
+ const newMode = event.target.value
+ setMode(newMode)
+ input.onChange(pidValueForMode(newMode, globalValue))
+ }
+
+ return (
+ <>
+
+ {choices.map((choice) => (
+
+ ))}
+
+ {mode === PID_CUSTOM && (
+ <>
+
+
+ {translate('resources.library.pid.help')}{' '}
+
+ {translate('resources.library.pid.docs')}
+
+
+ >
+ )}
+ >
+ )
+}
+
+PIDInput.propTypes = {
+ source: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ globalValue: PropTypes.string,
+ allowFolder: PropTypes.bool,
+}
+
+export const PIDInputs = () => {
+ const translate = useTranslate()
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/ui/src/library/pidPresets.js b/ui/src/library/pidPresets.js
new file mode 100644
index 000000000..0483fc691
--- /dev/null
+++ b/ui/src/library/pidPresets.js
@@ -0,0 +1,33 @@
+export const PID_GLOBAL = 'global'
+export const PID_FOLDER = 'folder'
+export const PID_CUSTOM = 'custom'
+
+export const pidModeFromValue = (value, allowFolder) => {
+ const v = (value || '').trim()
+ if (v === '') return PID_GLOBAL
+ if (allowFolder && v === PID_FOLDER) return PID_FOLDER
+ return PID_CUSTOM
+}
+
+// Returns the value to store for a dropdown choice. Custom starts from the global spec
+export const pidValueForMode = (mode, globalValue) => {
+ switch (mode) {
+ case PID_GLOBAL:
+ return ''
+ case PID_FOLDER:
+ return PID_FOLDER
+ default:
+ return globalValue || ''
+ }
+}
+
+// Reports whether the form values change the effective PID spec of the saved record. Like the
+// server, it trims, treats empty as the global value and compares case-insensitively
+export const pidConfigChanged = (values, record, globals) => {
+ const effective = (value, field) =>
+ ((value || '').trim() || globals[field] || '').toLowerCase()
+ return ['pidAlbum', 'pidTrack'].some(
+ (field) =>
+ effective(values[field], field) !== effective(record[field], field),
+ )
+}
diff --git a/ui/src/library/pidPresets.test.js b/ui/src/library/pidPresets.test.js
new file mode 100644
index 000000000..dab1c82e2
--- /dev/null
+++ b/ui/src/library/pidPresets.test.js
@@ -0,0 +1,65 @@
+import { describe, it, expect } from 'vitest'
+import {
+ PID_CUSTOM,
+ PID_FOLDER,
+ PID_GLOBAL,
+ pidConfigChanged,
+ pidModeFromValue,
+ pidValueForMode,
+} from './pidPresets'
+
+describe('pidModeFromValue', () => {
+ it('maps an empty value to the global setting', () => {
+ expect(pidModeFromValue('', true)).toBe(PID_GLOBAL)
+ expect(pidModeFromValue(undefined, true)).toBe(PID_GLOBAL)
+ })
+ it('maps folder to the Folder preset when allowed', () => {
+ expect(pidModeFromValue('folder', true)).toBe(PID_FOLDER)
+ })
+ it('maps folder to Custom when the Folder preset is not offered', () => {
+ expect(pidModeFromValue('folder', false)).toBe(PID_CUSTOM)
+ })
+ it('maps any other value to Custom', () => {
+ expect(pidModeFromValue('album|title', true)).toBe(PID_CUSTOM)
+ })
+})
+
+describe('pidValueForMode', () => {
+ it('stores an empty value for the global setting', () => {
+ expect(pidValueForMode(PID_GLOBAL, 'album')).toBe('')
+ })
+ it('stores folder for the Folder preset', () => {
+ expect(pidValueForMode(PID_FOLDER, '')).toBe('folder')
+ })
+ it('starts Custom from the global spec', () => {
+ expect(pidValueForMode(PID_CUSTOM, 'album|title')).toBe('album|title')
+ expect(pidValueForMode(PID_CUSTOM, undefined)).toBe('')
+ })
+})
+
+describe('pidConfigChanged', () => {
+ const record = { pidAlbum: 'folder', pidTrack: '' }
+ const globals = {
+ pidAlbum: 'musicbrainz_albumid|albumartistid,album',
+ pidTrack: 'musicbrainz_trackid|albumid,discnumber,tracknumber,title',
+ }
+ it.each([
+ ['nothing changed', { pidAlbum: 'folder', pidTrack: '' }, false],
+ ['a missing value equals an empty one', { pidAlbum: 'folder' }, false],
+ [
+ 'Custom set to the global value',
+ { pidAlbum: 'folder', pidTrack: globals.pidTrack },
+ false,
+ ],
+ ['a case-only change', { pidAlbum: 'FOLDER', pidTrack: '' }, false],
+ [
+ 'a whitespace-only change',
+ { pidAlbum: ' folder ', pidTrack: ' ' },
+ false,
+ ],
+ ['the album PID changed', { pidAlbum: '', pidTrack: '' }, true],
+ ['the track PID changed', { pidAlbum: 'folder', pidTrack: 'title' }, true],
+ ])('%s', (_, values, expected) => {
+ expect(pidConfigChanged(values, record, globals)).toBe(expected)
+ })
+})
diff --git a/ui/src/player/ApiKeyInput.jsx b/ui/src/player/ApiKeyInput.jsx
new file mode 100644
index 000000000..1f2dd1ed2
--- /dev/null
+++ b/ui/src/player/ApiKeyInput.jsx
@@ -0,0 +1,107 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { useInput, useNotify, useTranslate } from 'react-admin'
+import { Button, TextField } from '@material-ui/core'
+import { FaKey } from 'react-icons/fa'
+import { MdContentCopy, MdDelete, MdRefresh } from 'react-icons/md'
+import { isWritable } from '../common/playlistUtils'
+import { generateApiKey } from './apiKey'
+
+const identity = (v) => v
+const MASK = '•'.repeat(26)
+
+const ApiKeyInput = ({ record, isCreate, fullWidth, className, ...props }) => {
+ const translate = useTranslate()
+ const notify = useNotify()
+ // Identity format/parse keep "" (revoke) distinct from undefined (untouched)
+ const {
+ input: { value, onChange },
+ meta: { error, touched },
+ } = useInput({ ...props, format: identity, parse: identity })
+
+ const isOwner = isCreate || record?.userId === localStorage.getItem('userId')
+ const pending = !!value
+ const revoking = value === '' && !!record?.hasApiKey
+ const saved = value == null && !!record?.hasApiKey
+ const hasKey = pending || saved
+
+ const copy = () => {
+ const fallback = () =>
+ prompt(translate('message.shareCopyToClipboard'), value)
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard
+ .writeText(value)
+ .then(
+ () => notify('resources.player.notifications.apiKeyCopied'),
+ fallback,
+ )
+ } else {
+ fallback()
+ }
+ }
+
+ const helperText = pending
+ ? 'resources.player.message.apiKeyPending'
+ : revoking
+ ? 'resources.player.message.apiKeyRevokePending'
+ : saved
+ ? 'resources.player.message.apiKeyActive'
+ : isOwner
+ ? 'resources.player.message.apiKeyNone'
+ : 'resources.player.message.apiKeyNoneOther'
+
+ return (
+
+
+
+ {pending && (
+ } onClick={copy}>
+ {translate('resources.player.actions.copyApiKey')}
+
+ )}
+ {isOwner && (
+ : }
+ onClick={() => onChange(generateApiKey())}
+ >
+ {translate(
+ hasKey
+ ? 'resources.player.actions.regenerateApiKey'
+ : 'resources.player.actions.generateApiKey',
+ )}
+
+ )}
+ {saved && isWritable(record?.userId) && (
+ } onClick={() => onChange('')}>
+ {translate('resources.player.actions.revokeApiKey')}
+
+ )}
+
+
+ )
+}
+
+ApiKeyInput.propTypes = {
+ source: PropTypes.string.isRequired,
+ record: PropTypes.object,
+ isCreate: PropTypes.bool,
+ fullWidth: PropTypes.bool,
+ className: PropTypes.string,
+ validate: PropTypes.oneOfType([PropTypes.func, PropTypes.array]),
+}
+
+export default ApiKeyInput
diff --git a/ui/src/player/ApiKeyInput.test.jsx b/ui/src/player/ApiKeyInput.test.jsx
new file mode 100644
index 000000000..dd4269d6e
--- /dev/null
+++ b/ui/src/player/ApiKeyInput.test.jsx
@@ -0,0 +1,172 @@
+import * as React from 'react'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import { Form } from 'react-final-form'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import ApiKeyInput from './ApiKeyInput'
+
+const hooks = vi.hoisted(() => ({ notify: vi.fn() }))
+const KEY = 'nds_0123456789abcdefghijkl'
+const KEY_FORMAT = /^nds_[0-9A-Za-z]{22}$/
+
+vi.mock('react-admin', async () => {
+ const actual = await vi.importActual('react-admin')
+ return {
+ ...actual,
+ useNotify: () => hooks.notify,
+ useTranslate: () => (key) => key,
+ }
+})
+
+const renderInput = ({
+ record,
+ initialValues = {},
+ isCreate = false,
+ fullWidth,
+}) => {
+ let values
+ const utils = render(
+