-
+
({
- 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(),
- }
-})
+import { Details } from './AlbumDetails'
// Mock useMediaQuery
vi.mock('@material-ui/core', async () => {
@@ -365,49 +343,3 @@ 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 81e4dcd14..3cb8a648e 100644
--- a/ui/src/album/AlbumList.jsx
+++ b/ui/src/album/AlbumList.jsx
@@ -163,7 +163,6 @@ const AlbumFilter = (props) => {
/>
-
{config.enableFavourites && (
{
removeItems()
- // Only proxy-authenticated sessions go to the IdP; others (e.g. direct LAN access) get the login form
- if (config.extAuthLogoutURL && config.auth) {
+ if (config.extAuthLogoutURL) {
window.location.href = config.extAuthLogoutURL
return Promise.resolve(false)
}
@@ -95,6 +98,7 @@ const authProvider = {
id: localStorage.getItem('username'),
fullName: localStorage.getItem('name'),
avatar: localStorage.getItem('avatar'),
+ avatarTag: localStorage.getItem('avatarTag'),
})
},
}
@@ -105,6 +109,7 @@ 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
deleted file mode 100644
index b16a39815..000000000
--- a/ui/src/authProvider.test.js
+++ /dev/null
@@ -1,47 +0,0 @@
-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 dac9cff08..dce24a2b9 100644
--- a/ui/src/common/DateField.jsx
+++ b/ui/src/common/DateField.jsx
@@ -1,14 +1,12 @@
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
deleted file mode 100644
index 47d56756b..000000000
--- a/ui/src/common/DateField.test.jsx
+++ /dev/null
@@ -1,32 +0,0 @@
-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 a370e40fe..03144ddc0 100644
--- a/ui/src/common/ImageUploadOverlay.jsx
+++ b/ui/src/common/ImageUploadOverlay.jsx
@@ -3,6 +3,7 @@ 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'
@@ -36,11 +37,22 @@ 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()
@@ -48,8 +60,11 @@ export const ImageUploadOverlay = ({
const classes = useStyles()
const fileInputRef = useRef(null)
- const canEdit =
- config.enableArtworkUpload || localStorage.getItem('role') === 'admin'
+ 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 handleUploadClick = useCallback((e) => {
e.stopPropagation()
@@ -72,16 +87,24 @@ export const ImageUploadOverlay = ({
headers: new Headers({}),
body: formData,
})
- notify(`message.coverUploaded`, 'success')
- if (onImageChange) onImageChange()
+ notify(msg.uploaded, 'success')
+ if (onImageChange) onImageChange(true)
refresh()
} catch (err) {
- notify(`message.coverUploadError`, 'warning')
+ notify(msg.uploadError, 'warning')
}
e.target.value = ''
},
- [entityType, entityId, notify, refresh, onImageChange],
+ [
+ entityType,
+ entityId,
+ notify,
+ refresh,
+ onImageChange,
+ msg.uploaded,
+ msg.uploadError,
+ ],
)
const handleRemoveCover = useCallback(
@@ -93,21 +116,29 @@ export const ImageUploadOverlay = ({
await httpClient(`${REST_URL}/${entityType}/${entityId}/image`, {
method: 'DELETE',
})
- notify(`message.coverRemoved`, 'success')
- if (onImageChange) onImageChange()
+ notify(msg.removed, 'success')
+ if (onImageChange) onImageChange(false)
refresh()
} catch (err) {
- notify(`message.coverRemoveError`, 'warning')
+ notify(msg.removeError, 'warning')
}
},
- [entityType, entityId, notify, refresh, onImageChange],
+ [
+ entityType,
+ entityId,
+ notify,
+ refresh,
+ onImageChange,
+ msg.removed,
+ msg.removeError,
+ ],
)
- if (!canEdit) return null
+ if (!allowed) 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
new file mode 100644
index 000000000..904e49829
--- /dev/null
+++ b/ui/src/common/ImageUploadOverlay.test.jsx
@@ -0,0 +1,36 @@
+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 128b375c2..492ba95b3 100644
--- a/ui/src/common/LoveButton.jsx
+++ b/ui/src/common/LoveButton.jsx
@@ -6,8 +6,6 @@ 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'
@@ -42,7 +40,6 @@ 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) => {
@@ -64,7 +61,7 @@ export const LoveButton = ({
className={clsx(classes.love, className)}
title={
isDateSet(record.starredAt)
- ? formatDateTime(record.starredAt, locale)
+ ? new Date(record.starredAt).toLocaleString()
: undefined
}
{...rest}
diff --git a/ui/src/common/RatingField.jsx b/ui/src/common/RatingField.jsx
index f892b475a..f92b0d948 100644
--- a/ui/src/common/RatingField.jsx
+++ b/ui/src/common/RatingField.jsx
@@ -6,8 +6,6 @@ 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({
@@ -34,7 +32,6 @@ 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()
@@ -53,7 +50,7 @@ export const RatingField = ({
onClick={(e) => stopPropagation(e)}
title={
isDateSet(record.ratedAt)
- ? formatDateTime(record.ratedAt, locale)
+ ? new Date(record.ratedAt).toLocaleString()
: undefined
}
>
diff --git a/ui/src/common/ReadOnlyFields.jsx b/ui/src/common/ReadOnlyFields.jsx
deleted file mode 100644
index c56643fbd..000000000
--- a/ui/src/common/ReadOnlyFields.jsx
+++ /dev/null
@@ -1,99 +0,0 @@
-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
deleted file mode 100644
index 00543a786..000000000
--- a/ui/src/common/ReadOnlyFields.test.jsx
+++ /dev/null
@@ -1,121 +0,0 @@
-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 fb8f40f00..0177df326 100644
--- a/ui/src/common/index.js
+++ b/ui/src/common/index.js
@@ -15,7 +15,6 @@ 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 62b3cb822..39f0cd467 100644
--- a/ui/src/config.js
+++ b/ui/src/config.js
@@ -32,15 +32,12 @@ 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 46c76e194..472cd4940 100644
--- a/ui/src/consts.js
+++ b/ui/src/consts.js
@@ -31,9 +31,3 @@ 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 7de20bcce..e79beb787 100644
--- a/ui/src/dataProvider/wrapperDataProvider.js
+++ b/ui/src/dataProvider/wrapperDataProvider.js
@@ -148,12 +148,6 @@ 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) => {
@@ -200,9 +194,6 @@ 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) => {
@@ -227,15 +218,6 @@ 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 1c33aad5d..4225a5a54 100644
--- a/ui/src/dataProvider/wrapperDataProvider.test.js
+++ b/ui/src/dataProvider/wrapperDataProvider.test.js
@@ -88,21 +88,6 @@ 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
deleted file mode 100644
index cde7e9eaf..000000000
--- a/ui/src/dialogs/QuickConnectDialog.jsx
+++ /dev/null
@@ -1,128 +0,0 @@
-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
deleted file mode 100644
index 5dda1cbdf..000000000
--- a/ui/src/dialogs/QuickConnectDialog.test.jsx
+++ /dev/null
@@ -1,103 +0,0 @@
-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 3234e4685..86586aef0 100644
--- a/ui/src/dialogs/index.js
+++ b/ui/src/dialogs/index.js
@@ -2,5 +2,4 @@ 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 f694ea75f..6c23f393b 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -83,8 +83,7 @@
"grouping": "Grouping",
"media": "Media",
"mood": "Mood",
- "missing": "Missing",
- "played": "Played"
+ "missing": "Missing"
},
"actions": {
"playAll": "Play",
@@ -183,7 +182,6 @@
},
"player": {
"name": "Player |||| Players",
- "menuName": "Players & API keys",
"fields": {
"name": "Name",
"transcodingId": "Transcoding",
@@ -192,29 +190,7 @@
"userName": "Username",
"lastSeen": "Last Seen At",
"reportRealPath": "Report Real Path",
- "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"
+ "scrobbleEnabled": "Send Scrobbles to external services"
}
},
"transcoding": {
@@ -224,9 +200,6 @@
"targetFormat": "Target Format",
"defaultBitRate": "Default Bit Rate",
"command": "Command"
- },
- "choices": {
- "noDefaultBitRate": "None"
}
},
"playlist": {
@@ -331,22 +304,11 @@
"totalDuration": "Duration",
"defaultNewUsers": "Default for New Users",
"createdAt": "Created",
- "updatedAt": "Updated",
- "pidAlbum": "Album grouping",
- "pidTrack": "Track identity"
+ "updatedAt": "Updated"
},
"sections": {
"basic": "Basic Information",
- "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"
+ "statistics": "Statistics"
},
"actions": {
"scan": "Scan Library",
@@ -376,9 +338,7 @@
"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",
- "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."
+ "noLibrariesAssigned": "No libraries assigned to this user"
}
},
"plugin": {
@@ -611,6 +571,12 @@
"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.",
@@ -628,9 +594,6 @@
"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",
@@ -662,14 +625,6 @@
"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
deleted file mode 100644
index c9298a473..000000000
--- a/ui/src/i18n/useDateLocale.js
+++ /dev/null
@@ -1,14 +0,0 @@
-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
deleted file mode 100644
index c0ea5ab40..000000000
--- a/ui/src/i18n/useDateLocale.test.js
+++ /dev/null
@@ -1,45 +0,0 @@
-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 460d33bb9..561701dce 100644
--- a/ui/src/layout/AppBar.jsx
+++ b/ui/src/layout/AppBar.jsx
@@ -6,17 +6,12 @@ import {
usePermissions,
getResources,
} from 'react-admin'
-import {
- MdInfo,
- MdPerson,
- MdPhonelink,
- MdSupervisorAccount,
-} from 'react-icons/md'
+import { MdInfo, MdPerson, 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, QuickConnectDialog } from '../dialogs'
+import { AboutDialog } from '../dialogs'
import PersonalMenu from './PersonalMenu'
import ActivityPanel from './ActivityPanel'
import NowPlayingPanel from './NowPlayingPanel'
@@ -38,34 +33,33 @@ const useStyles = makeStyles(
},
)
-const DialogMenuItem = forwardRef(
- ({ onClick, label, icon, dialog, ...rest }, ref) => {
- const classes = useStyles(rest)
- const [open, setOpen] = React.useState(false)
+const AboutMenuItem = forwardRef(({ onClick, ...rest }, ref) => {
+ const classes = useStyles(rest)
+ const translate = useTranslate()
+ const [open, setOpen] = React.useState(false)
- const handleClose = () => {
- onClick && onClick()
- setOpen(false)
- }
- return (
- <>
-
- {createElement(dialog, { onClose: handleClose, open })}
- >
- )
- },
-)
+ const handleOpen = () => {
+ setOpen(true)
+ }
+ const handleClose = () => {
+ onClick && onClick()
+ setOpen(false)
+ }
+ const label = translate('menu.about')
+ return (
+ <>
+
+
+ >
+ )
+})
-DialogMenuItem.displayName = 'DialogMenuItem'
+AboutMenuItem.displayName = 'AboutMenuItem'
const settingsResources = (resource) =>
resource.name !== 'user' &&
@@ -102,11 +96,9 @@ const CustomUserMenu = ({ onClick, ...rest }) => {
}
const renderSettingsMenuItemLink = (resource, id) => {
- const label = resource.options.label
- ? translate(resource.options.label)
- : translate(`resources.${resource.name}.name`, {
- smart_count: id ? 1 : 2,
- })
+ const 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 cc5ef781d..f39dd75cb 100644
--- a/ui/src/layout/AppBar.test.jsx
+++ b/ui/src/layout/AppBar.test.jsx
@@ -9,14 +9,11 @@ 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: () => mocks.resources,
+ getResources: () => [],
}))
vi.mock('./NowPlayingPanel', () => ({
@@ -36,15 +33,12 @@ 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 },
})
@@ -68,42 +62,4 @@ 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 ff641ec22..001d3fc01 100644
--- a/ui/src/layout/Notification.jsx
+++ b/ui/src/layout/Notification.jsx
@@ -1,26 +1,11 @@
import React from 'react'
import { Notification as RANotification } from 'react-admin'
-import { makeStyles } from '@material-ui/core/styles'
-// 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 Notification = (props) => {
- const classes = useStyles()
- return (
-
- )
-}
-
export default Notification
diff --git a/ui/src/layout/UserMenu.jsx b/ui/src/layout/UserMenu.jsx
index e33185578..1b465ed35 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 } from 'react-admin'
+import { useTranslate, useGetIdentity, useVersion } from 'react-admin'
import {
Tooltip,
IconButton,
@@ -25,6 +25,7 @@ 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: {},
@@ -51,11 +52,25 @@ 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
@@ -81,10 +96,10 @@ const UserMenu = (props) => {
aria-haspopup={true}
onClick={handleMenu}
>
- {loaded && identity.avatar ? (
+ {loaded && avatarUrl ? (
) : (
diff --git a/ui/src/layout/UserMenu.test.jsx b/ui/src/layout/UserMenu.test.jsx
new file mode 100644
index 000000000..4a21b94ca
--- /dev/null
+++ b/ui/src/layout/UserMenu.test.jsx
@@ -0,0 +1,88 @@
+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 8166bb2f3..0e69964b6 100644
--- a/ui/src/library/LibraryCreate.jsx
+++ b/ui/src/library/LibraryCreate.jsx
@@ -1,5 +1,4 @@
import React, { useCallback } from 'react'
-import PropTypes from 'prop-types'
import {
Create,
SimpleForm,
@@ -11,34 +10,7 @@ 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()
@@ -101,12 +73,9 @@ const LibraryCreate = (props) => {
return (
} {...props}>
-
-
-
)
diff --git a/ui/src/library/LibraryEdit.jsx b/ui/src/library/LibraryEdit.jsx
index c42c7ac4b..7e89c892c 100644
--- a/ui/src/library/LibraryEdit.jsx
+++ b/ui/src/library/LibraryEdit.jsx
@@ -1,13 +1,12 @@
-import React, { useCallback, useState } from 'react'
-import PropTypes from 'prop-types'
+import React, { useCallback } from 'react'
import {
Edit,
FormWithRedirect,
TextInput,
BooleanInput,
- Confirm,
required,
SaveButton,
+ DateField,
useTranslate,
useMutation,
useNotify,
@@ -17,16 +16,8 @@ import {
import { Typography, Box } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import DeleteLibraryButton from './DeleteLibraryButton'
-import {
- ReadOnlyDateField,
- ReadOnlyDurationField,
- ReadOnlyNumberField,
- ReadOnlySizeField,
- Title,
-} from '../common'
-import config from '../config'
-import { PIDInputs } from './PIDInput'
-import { pidConfigChanged } from './pidPresets'
+import { Title } from '../common'
+import { formatBytes, formatDuration2, formatNumber } from '../utils/index.js'
const useStyles = makeStyles({
toolbar: {
@@ -35,8 +26,6 @@ const useStyles = makeStyles({
},
})
-const readOnlyProps = { resource: 'library', fullWidth: true }
-
const LibraryTitle = ({ record }) => {
const translate = useTranslate()
const resourceName = translate('resources.library.name', { smart_count: 1 })
@@ -58,131 +47,8 @@ const CustomToolbar = ({ showDelete, ...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 translate = useTranslate()
const [mutate] = useMutation()
const notify = useNotify()
const redirect = useRedirect()
@@ -221,11 +87,183 @@ const LibraryEdit = (props) => {
{...props}
save={save}
render={(formProps) => (
-
+
)}
/>
diff --git a/ui/src/library/LibraryEdit.test.jsx b/ui/src/library/LibraryEdit.test.jsx
deleted file mode 100644
index 926adc839..000000000
--- a/ui/src/library/LibraryEdit.test.jsx
+++ /dev/null
@@ -1,125 +0,0 @@
-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
deleted file mode 100644
index 6481dc392..000000000
--- a/ui/src/library/PIDInput.jsx
+++ /dev/null
@@ -1,114 +0,0 @@
-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
deleted file mode 100644
index 0483fc691..000000000
--- a/ui/src/library/pidPresets.js
+++ /dev/null
@@ -1,33 +0,0 @@
-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
deleted file mode 100644
index dab1c82e2..000000000
--- a/ui/src/library/pidPresets.test.js
+++ /dev/null
@@ -1,65 +0,0 @@
-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
deleted file mode 100644
index 1f2dd1ed2..000000000
--- a/ui/src/player/ApiKeyInput.jsx
+++ /dev/null
@@ -1,107 +0,0 @@
-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
deleted file mode 100644
index dd4269d6e..000000000
--- a/ui/src/player/ApiKeyInput.test.jsx
+++ /dev/null
@@ -1,172 +0,0 @@
-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(
-