-
+
({
+ 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/AlbumInfo.jsx b/ui/src/album/AlbumInfo.jsx
index 075841e43..1de904031 100644
--- a/ui/src/album/AlbumInfo.jsx
+++ b/ui/src/album/AlbumInfo.jsx
@@ -8,7 +8,6 @@ import {
ArrayField,
BooleanField,
ChipField,
- DateField,
FunctionField,
SingleFieldList,
TextField,
@@ -17,6 +16,7 @@ import {
} from 'react-admin'
import { makeStyles } from '@material-ui/core/styles'
import {
+ DateField,
ArtistLinkField,
MultiLineTextField,
ParticipantsInfo,
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..8aab34ae1 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 { DateField as RADateField, useRecordContext } from 'react-admin'
+import { useDateLocale } from '../i18n/useDateLocale'
export const DateField = (props) => {
- const { record, source } = props
- const value = record?.[source]
+ const record = useRecordContext(props)
+ const locale = useDateLocale()
+ const value = record?.[props.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..7494b7887
--- /dev/null
+++ b/ui/src/common/DateField.test.jsx
@@ -0,0 +1,42 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { RecordContextProvider } from 'react-admin'
+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()
+ })
+
+ it('reads the record from the record context', () => {
+ render(
+
+
+ ,
+ )
+ expect(screen.getByText('17.9.2026')).toBeInTheDocument()
+ })
+})
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/SongInfo.jsx b/ui/src/common/SongInfo.jsx
index 1b1a014f1..b9f7bea8c 100644
--- a/ui/src/common/SongInfo.jsx
+++ b/ui/src/common/SongInfo.jsx
@@ -6,7 +6,6 @@ import TableContainer from '@material-ui/core/TableContainer'
import TableRow from '@material-ui/core/TableRow'
import {
BooleanField,
- DateField,
TextField,
NumberField,
FunctionField,
@@ -15,6 +14,7 @@ import {
} from 'react-admin'
import { humanize, underscore } from 'inflection'
import {
+ DateField,
ArtistLinkField,
BitrateField,
ParticipantsInfo,
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..8f086f516 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": {
@@ -632,6 +677,11 @@
"options": {
"theme": "Theme",
"language": "Language",
+ "dateFormat": "Date Format",
+ "dateFormats": {
+ "language": "Match language",
+ "browser": "Match browser"
+ },
"defaultView": "Default View",
"desktop_notifications": "Desktop Notifications",
"lastfmNotConfigured": "Last.fm API-Key is not configured",
diff --git a/ui/src/i18n/useDateLocale.js b/ui/src/i18n/useDateLocale.js
new file mode 100644
index 000000000..f02402f29
--- /dev/null
+++ b/ui/src/i18n/useDateLocale.js
@@ -0,0 +1,22 @@
+import { useLocale } from 'react-admin'
+
+export const getStoredDateFormat = () =>
+ localStorage.getItem('dateFormat') || 'language'
+
+// 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
+ )
+}
+
+// With the "browser" date format, return undefined so Intl uses the browser's
+// own locale, regardless of the selected language.
+export const useDateLocale = () => {
+ const locale = useLocale()
+ if (getStoredDateFormat() === 'browser') return undefined
+ return resolveDateLocale(locale, navigator.languages)
+}
diff --git a/ui/src/i18n/useDateLocale.test.js b/ui/src/i18n/useDateLocale.test.js
new file mode 100644
index 000000000..16707a084
--- /dev/null
+++ b/ui/src/i18n/useDateLocale.test.js
@@ -0,0 +1,52 @@
+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()
+ localStorage.removeItem('dateFormat')
+ })
+
+ 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()
+ })
+
+ it('returns undefined to use the browser locale when set to browser', async () => {
+ localStorage.setItem('dateFormat', 'browser')
+ const result = await renderWith('en', ['fr-FR'])
+ 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/missing/MissingFilesList.jsx b/ui/src/missing/MissingFilesList.jsx
index a09f552d9..64ab0487d 100644
--- a/ui/src/missing/MissingFilesList.jsx
+++ b/ui/src/missing/MissingFilesList.jsx
@@ -1,4 +1,5 @@
import {
+ DateField,
List,
Pagination,
SizeField,
@@ -7,7 +8,6 @@ import {
} from '../common/index'
import {
Datagrid,
- DateField,
TextField,
downloadCSV,
Filter,
diff --git a/ui/src/personal/Personal.jsx b/ui/src/personal/Personal.jsx
index 84f9b63e6..75c4dd45a 100644
--- a/ui/src/personal/Personal.jsx
+++ b/ui/src/personal/Personal.jsx
@@ -2,6 +2,7 @@ import { SimpleForm, Title, useTranslate } from 'react-admin'
import { Card } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import { SelectLanguage } from './SelectLanguage'
+import { SelectDateFormat } from './SelectDateFormat'
import { SelectTheme } from './SelectTheme'
import { SelectDefaultView } from './SelectDefaultView'
import { NotificationsToggle } from './NotificationsToggle'
@@ -24,6 +25,7 @@ const Personal = () => {
+
{config.enableReplayGain && }
diff --git a/ui/src/personal/SelectDateFormat.jsx b/ui/src/personal/SelectDateFormat.jsx
new file mode 100644
index 000000000..fdc9583d2
--- /dev/null
+++ b/ui/src/personal/SelectDateFormat.jsx
@@ -0,0 +1,22 @@
+import { SelectInput, useTranslate } from 'react-admin'
+import { getStoredDateFormat } from '../i18n/useDateLocale'
+
+export const SelectDateFormat = (props) => {
+ const translate = useTranslate()
+
+ return (
+ {
+ localStorage.setItem('dateFormat', event.target.value)
+ }}
+ />
+ )
+}
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(
+
diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx
index d2b17b108..be77672a9 100644
--- a/ui/src/playlist/PlaylistList.jsx
+++ b/ui/src/playlist/PlaylistList.jsx
@@ -1,7 +1,6 @@
import React, { useMemo } from 'react'
import {
Datagrid,
- DateField,
EditButton,
Filter,
NullableBooleanInput,
@@ -20,6 +19,7 @@ import Switch from '@material-ui/core/Switch'
import { makeStyles } from '@material-ui/core/styles'
import { useMediaQuery } from '@material-ui/core'
import {
+ DateField,
ArtworkAvatar,
DurationField,
List,
@@ -67,15 +67,15 @@ const PlaylistFilter = (props) => {
)
}
-const TogglePublicInput = ({ resource, source }) => {
+export const ToggleField = ({ resource, source }) => {
const record = useRecordContext()
const notify = useNotify()
- const [togglePublic] = useUpdate(
+ const [toggle] = useUpdate(
resource,
- record.id,
+ record?.id,
{
...record,
- public: !record.public,
+ [source]: !record?.[source],
},
{
undoable: false,
@@ -86,48 +86,25 @@ const TogglePublicInput = ({ resource, source }) => {
)
const handleClick = (e) => {
- togglePublic()
+ toggle()
e.stopPropagation()
}
+ if (!record) return null
+
return (
)
}
-const ToggleAutoImport = ({ resource, source }) => {
+export const ToggleAutoImport = (props) => {
const record = useRecordContext()
- const notify = useNotify()
- const [ToggleAutoImport] = useUpdate(
- resource,
- record.id,
- {
- ...record,
- sync: !record.sync,
- },
- {
- undoable: false,
- onFailure: (error) => {
- notify('ra.page.error', 'warning')
- },
- },
- )
- const handleClick = (e) => {
- ToggleAutoImport()
- e.stopPropagation()
- }
-
- return record.path ? (
-
- ) : null
+ return record?.path ?
: null
}
const PlaylistListBulkActions = (props) => {
@@ -169,9 +146,7 @@ const PlaylistList = (props) => {
updatedAt: isDesktop && (
),
- public: !isXsmall && (
-
- ),
+ public: !isXsmall &&
,
comment:
,
sync: !isXsmall && (
diff --git a/ui/src/playlist/PlaylistList.test.jsx b/ui/src/playlist/PlaylistList.test.jsx
index 4fbc6d516..c05833166 100644
--- a/ui/src/playlist/PlaylistList.test.jsx
+++ b/ui/src/playlist/PlaylistList.test.jsx
@@ -1,7 +1,9 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
-import { PlaylistLove } from './PlaylistList'
+import { TestContext } from 'ra-test'
+import { RecordContextProvider } from 'react-admin'
+import { PlaylistLove, ToggleField, ToggleAutoImport } from './PlaylistList'
vi.mock('../config', () => ({
default: { enableFavourites: true },
@@ -13,6 +15,7 @@ vi.mock('../common', () => ({
{record?.starred ? 'starred' : 'not-starred'}
),
+ isWritable: (ownerId) => ownerId === 'me',
}))
describe('
', () => {
@@ -32,3 +35,50 @@ describe('
', () => {
})
})
})
+
+// react-admin evicts records older than 10 minutes while the list still holds
+// their ids, so rows can render with no record.
+describe('playlist toggles without a record', () => {
+ it('
renders nothing', () => {
+ const { container } = render(
+
+
+ ,
+ )
+ expect(container.innerHTML).toBe('')
+ })
+
+ it('
renders nothing', () => {
+ const { container } = render(
+
+
+ ,
+ )
+ expect(container.innerHTML).toBe('')
+ })
+})
+
+// Secondary is a surface color in many themes, so these toggles must use primary
+describe('
', () => {
+ const renderToggle = (record) =>
+ render(
+
+
+
+
+ ,
+ )
+
+ it.each([
+ ['owner', 'me', false],
+ ['non-owner', 'someone-else', true],
+ ])('renders a primary-colored switch for the %s', (_, ownerId, disabled) => {
+ renderToggle({ id: 'pl-1', public: true, ownerId })
+ const input = screen.getByRole('checkbox')
+ const switchBase = input.closest('.MuiSwitch-switchBase')
+ expect(input.checked).toBe(true)
+ expect(input.disabled).toBe(disabled)
+ expect(switchBase.classList).toContain('MuiSwitch-colorPrimary')
+ expect(switchBase.classList).not.toContain('MuiSwitch-colorSecondary')
+ })
+})
diff --git a/ui/src/radio/RadioEdit.jsx b/ui/src/radio/RadioEdit.jsx
index bbe001e6f..af879deaa 100644
--- a/ui/src/radio/RadioEdit.jsx
+++ b/ui/src/radio/RadioEdit.jsx
@@ -1,5 +1,4 @@
import {
- DateField,
Edit,
required,
SimpleForm,
@@ -9,7 +8,12 @@ import {
import { CardMedia } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import { urlValidate } from '../utils/validations'
-import { Title, ImageUploadOverlay, useImageLoadingState } from '../common'
+import {
+ Title,
+ ImageUploadOverlay,
+ ReadOnlyDateField,
+ useImageLoadingState,
+} from '../common'
import subsonic from '../subsonic'
import config from '../config'
import { RADIO_PLACEHOLDER_IMAGE } from '../consts'
@@ -65,8 +69,8 @@ const RadioEdit = (props) => {
fullWidth
validate={[urlValidate]}
/>
-
-
+
+
)
diff --git a/ui/src/radio/RadioList.jsx b/ui/src/radio/RadioList.jsx
index 0cfb2cd68..46e59ffcc 100644
--- a/ui/src/radio/RadioList.jsx
+++ b/ui/src/radio/RadioList.jsx
@@ -3,7 +3,6 @@ import React, { cloneElement } from 'react'
import {
CreateButton,
Datagrid,
- DateField,
EditButton,
Filter,
sanitizeListRestProps,
@@ -15,6 +14,7 @@ import {
useTranslate,
} from 'react-admin'
import {
+ DateField,
List,
defaultRowsPerPageOptions,
getStoredPerPage,
diff --git a/ui/src/share/ShareEdit.jsx b/ui/src/share/ShareEdit.jsx
index 2cf7f2df7..a222d3369 100644
--- a/ui/src/share/ShareEdit.jsx
+++ b/ui/src/share/ShareEdit.jsx
@@ -2,13 +2,16 @@ import {
DateTimeInput,
BooleanInput,
Edit,
- NumberField,
SimpleForm,
TextInput,
} from 'react-admin'
import { sharePlayerUrl } from '../utils'
import { Link } from '@material-ui/core'
-import { DateField } from '../common'
+import {
+ ReadOnlyDateField,
+ ReadOnlyNumberField,
+ ReadOnlyTextField,
+} from '../common'
import config from '../config'
export const ShareEdit = (props) => {
@@ -16,20 +19,26 @@ export const ShareEdit = (props) => {
const url = sharePlayerUrl(id)
return (
-
-
+
+
{url}
{config.enableDownloads && }
-
-
-
-
-
-
-
+
+
+
+
+
+
+
)
diff --git a/ui/src/themes/amusic.js b/ui/src/themes/amusic.js
index 55205baf3..eef7f90d6 100644
--- a/ui/src/themes/amusic.js
+++ b/ui/src/themes/amusic.js
@@ -79,6 +79,16 @@ export default {
color: '#eee',
backgroundColor: '#ff4e6b',
},
+ containedPrimary: {
+ color: '#fff',
+ backgroundColor: '#D60017',
+ '&:hover': {
+ backgroundColor: '#a30011',
+ '@media (hover: none)': {
+ backgroundColor: '#D60017',
+ },
+ },
+ },
textSizeSmall: {
fontSize: '0.8rem',
paddingRight: '0.5rem',
@@ -192,6 +202,11 @@ export default {
paddingBottom: '1rem',
},
},
+ NDNotification: {
+ undo: {
+ color: '#fff',
+ },
+ },
RaConfirm: {
confirmPrimary: {
color: '#fff',
diff --git a/ui/src/themes/dracula.js b/ui/src/themes/dracula.js
index 2e4ae38e5..45559c3af 100644
--- a/ui/src/themes/dracula.js
+++ b/ui/src/themes/dracula.js
@@ -185,16 +185,6 @@ export default {
color: `${foreground} !important`,
},
},
- MuiSwitch: {
- colorSecondary: {
- '&$checked': {
- color: green,
- },
- '&$checked + $track': {
- backgroundColor: green,
- },
- },
- },
NDAlbumGridView: {
albumName: {
marginTop: '0.5rem',
diff --git a/ui/src/themes/gruvboxDark.js b/ui/src/themes/gruvboxDark.js
index 0f4cbd7c4..3e2955dcd 100644
--- a/ui/src/themes/gruvboxDark.js
+++ b/ui/src/themes/gruvboxDark.js
@@ -121,16 +121,6 @@ export default {
boxShadow: '3px 3px 5px #3c3836',
},
},
- MuiSwitch: {
- colorSecondary: {
- '&$checked': {
- color: '#458588',
- },
- '&$checked + $track': {
- backgroundColor: '#458588',
- },
- },
- },
NDMobileArtistDetails: {
bgContainer: {
background:
diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js
index 391a97f2c..285e579ae 100644
--- a/ui/src/themes/index.js
+++ b/ui/src/themes/index.js
@@ -25,6 +25,8 @@ import MoonbaseAlphaTheme from './moonbaseAlpha'
import MoonbaseBravoTheme from './moonbaseBravo'
import TokyoNightLightTheme from './tokyoNightLight'
import TokyoNightTheme from './tokyoNight'
+import SolarizedDarkTheme from './solarizedDark'
+import SolarizedLightTheme from './solarizedLight'
export default {
// Classic default themes
@@ -53,6 +55,8 @@ export default {
RosePineDawnTheme,
RosePineMoonTheme,
RosePineTheme,
+ SolarizedDarkTheme,
+ SolarizedLightTheme,
SpotifyTheme,
SquiddiesGlassTheme,
TokyoNightLightTheme,
diff --git a/ui/src/themes/nuclear.js b/ui/src/themes/nuclear.js
index b34896c97..bf804139e 100644
--- a/ui/src/themes/nuclear.js
+++ b/ui/src/themes/nuclear.js
@@ -86,6 +86,13 @@ export default {
},
},
},
+ NDNotification: {
+ undo: {
+ '& .MuiButton-label': {
+ color: 'inherit',
+ },
+ },
+ },
MuiChip: {
root: {
backgroundColor: nukeCol['accent'],
diff --git a/ui/src/themes/solarizedDark.css.js b/ui/src/themes/solarizedDark.css.js
new file mode 100644
index 000000000..b357b8a0b
--- /dev/null
+++ b/ui/src/themes/solarizedDark.css.js
@@ -0,0 +1,148 @@
+const stylesheet = `
+
+.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover {
+ color: #268bd2
+}
+
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track {
+ background-color: #268bd2
+}
+
+.react-jinke-music-player-main ::-webkit-scrollbar-thumb {
+ background-color: #268bd2;
+}
+
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
+ box-shadow: 0 0 2px #268bd2
+}
+
+.react-jinke-music-player-main .audio-item.playing svg {
+ color: #268bd2
+}
+
+.react-jinke-music-player-main .audio-item.playing .player-singer {
+ color: #268bd2 !important
+}
+
+.react-jinke-music-player-main .loading svg {
+ color: #268bd2 !important
+}
+
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle {
+ border: hidden;
+ box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px;
+}
+
+.rc-slider-rail, .rc-slider-track {
+ height: 6px;
+}
+
+.react-jinke-music-player-main .rc-slider-rail,
+.react-jinke-music-player-mobile-progress .rc-slider-rail {
+ background-color: rgba(147, 161, 161, .2) !important
+}
+
+.rc-slider {
+ padding: 3px 0;
+}
+
+.sound-operation > div:nth-child(4) {
+ transform: translateX(-50%) translateY(5%) !important;
+}
+
+.sound-operation {
+ padding: 4px 0;
+}
+
+.react-jinke-music-player-main .music-player-panel {
+ background-color: #073642;
+ color: #93a1a1;
+ box-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
+}
+
+.audio-lists-panel {
+ background-color: #073642;
+ bottom: 6.25rem;
+ box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px;
+}
+
+.audio-lists-panel-content .audio-item.playing {
+ background-color: rgba(0, 0, 0, 0);
+}
+
+.audio-lists-panel-content .audio-item:nth-child(2n+1) {
+ background-color: rgba(0, 0, 0, 0);
+}
+
+.audio-lists-panel-content .audio-item:active,
+.audio-lists-panel-content .audio-item:hover {
+ background-color: #0d4251;
+}
+
+.audio-lists-panel-header {
+ border-bottom: 1px solid rgba(0, 0, 0, 0.25);
+ box-shadow: none;
+}
+
+.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn {
+ background-color: rgba(0, 0, 0, 0);
+ box-shadow: 0 0 0 0;
+}
+
+.audio-lists-panel-content .audio-item {
+ line-height: 32px;
+}
+
+.react-jinke-music-player-main .music-player-panel .panel-content .img-content {
+ box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px;
+}
+
+.react-jinke-music-player-main .music-player-lyric {
+ color: #93a1a1;
+ -webkit-text-stroke: 0.5px #002b36;
+ font-weight: bolder;
+}
+
+.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg {
+ color: #268bd2 !important;
+}
+
+.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg {
+ color: #268bd2
+}
+
+.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg {
+ color: #268bd2
+}
+
+.audio-lists-panel-content .audio-item .player-icons {
+ scale: 75%;
+}
+
+/* Mobile */
+
+.react-jinke-music-player-mobile-cover {
+ border: none;
+ box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px;
+}
+
+.react-jinke-music-player .music-player-controller {
+ border: none;
+ box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px;
+ color: #268bd2;
+}
+
+.react-jinke-music-player .music-player-controller .music-player-controller-setting {
+ color: rgba(38, 139, 210, .3);
+}
+
+.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track {
+ background-color: #268bd2;
+}
+
+.react-jinke-music-player-mobile-progress .rc-slider-handle {
+ border: none;
+}
+`
+
+export default stylesheet
diff --git a/ui/src/themes/solarizedDark.js b/ui/src/themes/solarizedDark.js
new file mode 100644
index 000000000..1e1fcdff4
--- /dev/null
+++ b/ui/src/themes/solarizedDark.js
@@ -0,0 +1,380 @@
+import stylesheet from './solarizedDark.css.js'
+
+// Solarized palette by Ethan Schoonover: https://ethanschoonover.com/solarized/
+const background = '#002b36' // base03
+const surface = '#073642' // base02
+const currentLine = '#0d4251' // base02, slightly lighter for hover and headers
+const foreground = '#93a1a1' // base1
+const comment = '#839496' // base0
+const blue = '#268bd2'
+const link = '#57b5ae' // cyan, lightened to keep text links above 4.5:1
+const accent = '#2aa198' // cyan, since violet is too dark on base03
+const red = '#dc322f'
+
+// For Album, Playlist play button
+const musicListActions = {
+ alignItems: 'center',
+ '@global': {
+ 'button:first-child:not(:only-child)': {
+ '@media screen and (max-width: 720px)': {
+ transform: 'scale(1.5)',
+ margin: '1rem',
+ '&:hover': {
+ transform: 'scale(1.6) !important',
+ },
+ },
+ transform: 'scale(2)',
+ margin: '1.5rem',
+ minWidth: 0,
+ padding: 5,
+ transition: 'transform .3s ease',
+ backgroundColor: `${blue} !important`,
+ color: background,
+ borderRadius: 500,
+ border: 0,
+ '&:hover': {
+ transform: 'scale(2.1)',
+ backgroundColor: `${blue} !important`,
+ border: 0,
+ },
+ },
+ 'button:only-child': {
+ margin: '1.5rem',
+ },
+ 'button:first-child>span:first-child': {
+ padding: 0,
+ },
+ 'button:first-child>span:first-child>span': {
+ display: 'none',
+ },
+ 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg':
+ {
+ color: foreground,
+ },
+ },
+}
+
+export default {
+ themeName: 'Solarized Dark',
+ palette: {
+ primary: {
+ main: blue,
+ },
+ secondary: {
+ main: accent,
+ contrastText: background,
+ },
+ error: {
+ main: red,
+ },
+ type: 'dark',
+ background: {
+ default: background,
+ paper: surface,
+ },
+ },
+ overrides: {
+ MuiPaper: {
+ root: {
+ color: foreground,
+ backgroundColor: surface,
+ },
+ },
+ MuiAppBar: {
+ positionFixed: {
+ backgroundColor: `${surface} !important`,
+ boxShadow:
+ 'rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px',
+ },
+ colorSecondary: {
+ color: foreground,
+ },
+ },
+ MuiDrawer: {
+ root: {
+ background: background,
+ },
+ },
+ MuiButton: {
+ textPrimary: {
+ color: blue,
+ },
+ textSecondary: {
+ color: foreground,
+ },
+ },
+ MuiIconButton: {
+ root: {
+ color: foreground,
+ },
+ },
+ MuiChip: {
+ root: {
+ backgroundColor: currentLine,
+ },
+ },
+ MuiFormGroup: {
+ root: {
+ color: foreground,
+ },
+ },
+ MuiFormLabel: {
+ root: {
+ color: comment,
+ '&$focused': {
+ color: blue,
+ },
+ },
+ },
+ MuiFormHelperText: {
+ error: {
+ color: red,
+ },
+ },
+ MuiToolbar: {
+ root: {
+ backgroundColor: `${surface} !important`,
+ },
+ },
+ MuiOutlinedInput: {
+ root: {
+ '& $notchedOutline': {
+ borderColor: currentLine,
+ },
+ '&:hover $notchedOutline': {
+ borderColor: comment,
+ },
+ '&$focused $notchedOutline': {
+ borderColor: blue,
+ },
+ },
+ },
+ MuiFilledInput: {
+ root: {
+ backgroundColor: currentLine,
+ '&:hover': {
+ backgroundColor: comment,
+ },
+ '&$focused': {
+ backgroundColor: currentLine,
+ },
+ },
+ },
+ MuiTableRow: {
+ root: {
+ transition: 'background-color .3s ease',
+ '&:hover': {
+ backgroundColor: `${currentLine} !important`,
+ },
+ // Cells have an opaque background, so the hover must be applied to them too
+ '&:hover > .MuiTableCell-root': {
+ background: `${currentLine} !important`,
+ },
+ },
+ },
+ MuiTableHead: {
+ root: {
+ color: foreground,
+ background: surface,
+ },
+ },
+ MuiTableCell: {
+ root: {
+ color: foreground,
+ background: `${surface} !important`,
+ borderBottom: `1px solid ${currentLine}`,
+ },
+ head: {
+ color: `${foreground} !important`,
+ background: `${currentLine} !important`,
+ },
+ body: {
+ color: `${foreground} !important`,
+ },
+ },
+ NDAlbumGridView: {
+ albumName: {
+ marginTop: '0.5rem',
+ fontWeight: 700,
+ color: foreground,
+ },
+ albumSubtitle: {
+ color: comment,
+ },
+ albumContainer: {
+ backgroundColor: surface,
+ borderRadius: '8px',
+ padding: '.75rem',
+ transition: 'background-color .3s ease',
+ '&:hover': {
+ backgroundColor: currentLine,
+ },
+ },
+ albumPlayButton: {
+ backgroundColor: blue,
+ borderRadius: '50%',
+ boxShadow: '0 8px 8px rgb(0 0 0 / 30%)',
+ padding: '0.35rem',
+ transition: 'padding .3s ease',
+ '&:hover': {
+ background: `${blue} !important`,
+ padding: '0.45rem',
+ },
+ },
+ },
+ NDPlaylistDetails: {
+ container: {
+ background: `linear-gradient(${currentLine}, transparent)`,
+ borderRadius: 0,
+ paddingTop: '2.5rem !important',
+ boxShadow: 'none',
+ },
+ title: {
+ fontWeight: 700,
+ color: foreground,
+ },
+ details: {
+ fontSize: '.875rem',
+ color: comment,
+ },
+ },
+ NDAlbumDetails: {
+ root: {
+ background: `linear-gradient(${currentLine}, transparent)`,
+ borderRadius: 0,
+ boxShadow: 'none',
+ },
+ cardContents: {
+ alignItems: 'center',
+ paddingTop: '1.5rem',
+ },
+ recordName: {
+ fontWeight: 700,
+ color: foreground,
+ },
+ recordArtist: {
+ fontSize: '.875rem',
+ fontWeight: 700,
+ color: accent,
+ },
+ recordMeta: {
+ fontSize: '.875rem',
+ color: comment,
+ },
+ },
+ NDCollapsibleComment: {
+ commentBlock: {
+ fontSize: '.875rem',
+ color: comment,
+ },
+ },
+ NDAlbumShow: {
+ albumActions: musicListActions,
+ },
+ NDPlaylistShow: {
+ playlistActions: musicListActions,
+ },
+ NDAudioPlayer: {
+ audioTitle: {
+ color: foreground,
+ fontSize: '0.875rem',
+ },
+ songTitle: {
+ fontWeight: 400,
+ },
+ songInfo: {
+ fontSize: '0.675rem',
+ color: comment,
+ },
+ },
+ NDLogin: {
+ systemNameLink: {
+ color: link,
+ },
+ welcome: {
+ color: foreground,
+ },
+ card: {
+ minWidth: 300,
+ background: surface,
+ },
+ button: {
+ boxShadow: '3px 3px 5px #00212b',
+ },
+ },
+ NDMobileArtistDetails: {
+ bgContainer: {
+ background: `linear-gradient(to bottom, rgba(0 43 54 / 72%), ${background})!important`,
+ },
+ },
+ RaLayout: {
+ content: {
+ padding: '0 !important',
+ background: background,
+ },
+ root: {
+ backgroundColor: background,
+ },
+ },
+ RaList: {
+ content: {
+ backgroundColor: background,
+ },
+ },
+ RaListToolbar: {
+ toolbar: {
+ backgroundColor: background,
+ padding: '0 .55rem !important',
+ },
+ },
+ RaSidebar: {
+ fixed: {
+ backgroundColor: background,
+ },
+ drawerPaper: {
+ backgroundColor: `${background} !important`,
+ },
+ },
+ RaMenuItemLink: {
+ root: {
+ color: foreground,
+ '&[aria-current="page"]': {
+ color: `${blue} !important`,
+ },
+ '&[aria-current="page"] .MuiListItemIcon-root': {
+ color: `${blue} !important`,
+ },
+ },
+ active: {
+ color: `${blue} !important`,
+ '& .MuiListItemIcon-root': {
+ color: `${blue} !important`,
+ },
+ },
+ },
+ RaLink: {
+ link: {
+ color: link,
+ },
+ },
+ RaButton: {
+ button: {
+ margin: '0 5px 0 5px',
+ },
+ },
+ RaPaginationActions: {
+ currentPageButton: {
+ border: `2px solid ${blue}`,
+ },
+ button: {
+ backgroundColor: currentLine,
+ minWidth: 48,
+ margin: '0 4px',
+ },
+ },
+ },
+ player: {
+ theme: 'dark',
+ stylesheet,
+ },
+}
diff --git a/ui/src/themes/solarizedLight.css.js b/ui/src/themes/solarizedLight.css.js
new file mode 100644
index 000000000..c6a599562
--- /dev/null
+++ b/ui/src/themes/solarizedLight.css.js
@@ -0,0 +1,123 @@
+const stylesheet = `
+.react-jinke-music-player-main.light-theme .loading svg {
+ color: #268bd2;
+ font-size: 24px
+}
+
+.react-jinke-music-player-mobile-play-model-tip {
+ background-color: #268bd2;
+}
+
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track {
+ background-color: #268bd2
+}
+
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
+ box-shadow: 0 0 2px #268bd2
+}
+
+.react-jinke-music-player-main.light-theme .audio-item.playing svg {
+ color: #268bd2
+}
+
+.react-jinke-music-player-main.light-theme .audio-item.playing .player-singer {
+ color: #268bd2 !important
+}
+
+.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg {
+ color: #268bd2
+}
+
+.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg {
+ color: #268bd2
+}
+
+.react-jinke-music-player-main.light-theme ::-webkit-scrollbar-thumb {
+ background-color: #268bd2;
+}
+
+.react-jinke-music-player-main.light-theme svg {
+ color: #586e75
+}
+
+.react-jinke-music-player-main.light-theme svg:active, .react-jinke-music-player-main.light-theme svg:hover {
+ color: #268bd2
+}
+
+.react-jinke-music-player-main.light-theme .rc-slider-rail {
+ background-color: rgba(88, 110, 117, .12) !important
+}
+
+.react-jinke-music-player-main.light-theme .music-player-controller {
+ background-color: #fdf6e3;
+ border-color: #fdf6e3
+}
+
+.react-jinke-music-player-main.light-theme .music-player-panel {
+ background-color: #fdf6e3;
+ box-shadow: 0 1px 2px 0 rgba(0, 43, 54, .05);
+ color: #586e75
+}
+
+.react-jinke-music-player-main.light-theme .music-player-panel .img-content {
+ box-shadow: 0 0 10px #e4ddc6
+}
+
+.react-jinke-music-player-main.light-theme .music-player-panel .progress-load-bar {
+ background-color: rgba(88, 110, 117, .08) !important
+}
+
+.react-jinke-music-player-main.light-theme .rc-switch {
+ color: #fdf6e3
+}
+
+.react-jinke-music-player-main.light-theme .rc-switch:after {
+ background-color: #fdf6e3
+}
+
+.react-jinke-music-player-main.light-theme .rc-switch-checked {
+ background-color: #268bd2 !important;
+ border: 1px solid #268bd2
+}
+
+.react-jinke-music-player-main.light-theme .rc-switch-inner {
+ color: #fdf6e3
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-btn {
+ background-color: #eee8d5 !important
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-btn:active, .react-jinke-music-player-main.light-theme .audio-lists-btn:hover {
+ background-color: #e4ddc6;
+ color: #586e75
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-btn > .group:hover, .react-jinke-music-player-main.light-theme .audio-lists-btn > .group:hover > svg {
+ color: #268bd2
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-panel {
+ background-color: #fdf6e3;
+ box-shadow: 0 0 2px #e4ddc6;
+ color: #586e75
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item {
+ background-color: #fdf6e3
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item:nth-child(odd) {
+ background-color: #f5efdc !important
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing {
+ background-color: #e4ddc6 !important
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing, .react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing svg {
+ color: #268bd2 !important
+}
+`
+
+export default stylesheet
diff --git a/ui/src/themes/solarizedLight.js b/ui/src/themes/solarizedLight.js
new file mode 100644
index 000000000..65e0241e9
--- /dev/null
+++ b/ui/src/themes/solarizedLight.js
@@ -0,0 +1,380 @@
+import stylesheet from './solarizedLight.css.js'
+
+// Solarized palette by Ethan Schoonover: https://ethanschoonover.com/solarized/
+const background = '#eee8d5' // base2
+const surface = '#fdf6e3' // base3
+const currentLine = '#e4ddc6' // base2, slightly darker for hover and headers
+const foreground = '#586e75' // base01
+const comment = '#657b83' // base00
+const blue = '#268bd2'
+const link = '#1c6599' // blue, darkened to keep text links above 4.5:1
+const accent = '#5d61a9' // violet, darkened to keep text above 4.5:1
+const red = '#dc322f'
+
+// For Album, Playlist play button
+const musicListActions = {
+ alignItems: 'center',
+ '@global': {
+ 'button:first-child:not(:only-child)': {
+ '@media screen and (max-width: 720px)': {
+ transform: 'scale(1.5)',
+ margin: '1rem',
+ '&:hover': {
+ transform: 'scale(1.6) !important',
+ },
+ },
+ transform: 'scale(2)',
+ margin: '1.5rem',
+ minWidth: 0,
+ padding: 5,
+ transition: 'transform .3s ease',
+ backgroundColor: `${blue} !important`,
+ color: background,
+ borderRadius: 500,
+ border: 0,
+ '&:hover': {
+ transform: 'scale(2.1)',
+ backgroundColor: `${blue} !important`,
+ border: 0,
+ },
+ },
+ 'button:only-child': {
+ margin: '1.5rem',
+ },
+ 'button:first-child>span:first-child': {
+ padding: 0,
+ },
+ 'button:first-child>span:first-child>span': {
+ display: 'none',
+ },
+ 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg':
+ {
+ color: foreground,
+ },
+ },
+}
+
+export default {
+ themeName: 'Solarized Light',
+ palette: {
+ primary: {
+ main: blue,
+ },
+ secondary: {
+ main: accent,
+ contrastText: surface,
+ },
+ error: {
+ main: red,
+ },
+ type: 'light',
+ background: {
+ default: background,
+ paper: surface,
+ },
+ },
+ overrides: {
+ MuiPaper: {
+ root: {
+ color: foreground,
+ backgroundColor: surface,
+ },
+ },
+ MuiAppBar: {
+ positionFixed: {
+ backgroundColor: `${surface} !important`,
+ boxShadow:
+ 'rgba(0, 43, 54, 0.12) 0px 4px 6px, rgba(0, 43, 54, 0.06) 0px 5px 7px',
+ },
+ colorSecondary: {
+ color: foreground,
+ },
+ },
+ MuiDrawer: {
+ root: {
+ background: background,
+ },
+ },
+ MuiButton: {
+ textPrimary: {
+ color: blue,
+ },
+ textSecondary: {
+ color: foreground,
+ },
+ },
+ MuiIconButton: {
+ root: {
+ color: foreground,
+ },
+ },
+ MuiChip: {
+ root: {
+ backgroundColor: currentLine,
+ },
+ },
+ MuiFormGroup: {
+ root: {
+ color: foreground,
+ },
+ },
+ MuiFormLabel: {
+ root: {
+ color: comment,
+ '&$focused': {
+ color: blue,
+ },
+ },
+ },
+ MuiFormHelperText: {
+ error: {
+ color: red,
+ },
+ },
+ MuiToolbar: {
+ root: {
+ backgroundColor: `${surface} !important`,
+ },
+ },
+ MuiOutlinedInput: {
+ root: {
+ '& $notchedOutline': {
+ borderColor: currentLine,
+ },
+ '&:hover $notchedOutline': {
+ borderColor: comment,
+ },
+ '&$focused $notchedOutline': {
+ borderColor: blue,
+ },
+ },
+ },
+ MuiFilledInput: {
+ root: {
+ backgroundColor: currentLine,
+ '&:hover': {
+ backgroundColor: comment,
+ },
+ '&$focused': {
+ backgroundColor: currentLine,
+ },
+ },
+ },
+ MuiTableRow: {
+ root: {
+ transition: 'background-color .3s ease',
+ '&:hover': {
+ backgroundColor: `${currentLine} !important`,
+ },
+ // Cells have an opaque background, so the hover must be applied to them too
+ '&:hover > .MuiTableCell-root': {
+ background: `${currentLine} !important`,
+ },
+ },
+ },
+ MuiTableHead: {
+ root: {
+ color: foreground,
+ background: surface,
+ },
+ },
+ MuiTableCell: {
+ root: {
+ color: foreground,
+ background: `${surface} !important`,
+ borderBottom: `1px solid ${currentLine}`,
+ },
+ head: {
+ color: `${foreground} !important`,
+ background: `${currentLine} !important`,
+ },
+ body: {
+ color: `${foreground} !important`,
+ },
+ },
+ NDAlbumGridView: {
+ albumName: {
+ marginTop: '0.5rem',
+ fontWeight: 700,
+ color: foreground,
+ },
+ albumSubtitle: {
+ color: comment,
+ },
+ albumContainer: {
+ backgroundColor: surface,
+ borderRadius: '8px',
+ padding: '.75rem',
+ transition: 'background-color .3s ease',
+ '&:hover': {
+ backgroundColor: currentLine,
+ },
+ },
+ albumPlayButton: {
+ backgroundColor: blue,
+ borderRadius: '50%',
+ boxShadow: '0 8px 8px rgb(0 0 0 / 20%)',
+ padding: '0.35rem',
+ transition: 'padding .3s ease',
+ '&:hover': {
+ background: `${blue} !important`,
+ padding: '0.45rem',
+ },
+ },
+ },
+ NDPlaylistDetails: {
+ container: {
+ background: `linear-gradient(${currentLine}, transparent)`,
+ borderRadius: 0,
+ paddingTop: '2.5rem !important',
+ boxShadow: 'none',
+ },
+ title: {
+ fontWeight: 700,
+ color: foreground,
+ },
+ details: {
+ fontSize: '.875rem',
+ color: comment,
+ },
+ },
+ NDAlbumDetails: {
+ root: {
+ background: `linear-gradient(${currentLine}, transparent)`,
+ borderRadius: 0,
+ boxShadow: 'none',
+ },
+ cardContents: {
+ alignItems: 'center',
+ paddingTop: '1.5rem',
+ },
+ recordName: {
+ fontWeight: 700,
+ color: foreground,
+ },
+ recordArtist: {
+ fontSize: '.875rem',
+ fontWeight: 700,
+ color: accent,
+ },
+ recordMeta: {
+ fontSize: '.875rem',
+ color: comment,
+ },
+ },
+ NDCollapsibleComment: {
+ commentBlock: {
+ fontSize: '.875rem',
+ color: comment,
+ },
+ },
+ NDAlbumShow: {
+ albumActions: musicListActions,
+ },
+ NDPlaylistShow: {
+ playlistActions: musicListActions,
+ },
+ NDAudioPlayer: {
+ audioTitle: {
+ color: foreground,
+ fontSize: '0.875rem',
+ },
+ songTitle: {
+ fontWeight: 400,
+ },
+ songInfo: {
+ fontSize: '0.675rem',
+ color: comment,
+ },
+ },
+ NDLogin: {
+ systemNameLink: {
+ color: link,
+ },
+ welcome: {
+ color: foreground,
+ },
+ card: {
+ minWidth: 300,
+ background: surface,
+ },
+ button: {
+ boxShadow: '3px 3px 5px #93a1a1',
+ },
+ },
+ NDMobileArtistDetails: {
+ bgContainer: {
+ background: `linear-gradient(to bottom, rgba(238 232 213 / 72%), ${background})!important`,
+ },
+ },
+ RaLayout: {
+ content: {
+ padding: '0 !important',
+ background: background,
+ },
+ root: {
+ backgroundColor: background,
+ },
+ },
+ RaList: {
+ content: {
+ backgroundColor: background,
+ },
+ },
+ RaListToolbar: {
+ toolbar: {
+ backgroundColor: background,
+ padding: '0 .55rem !important',
+ },
+ },
+ RaSidebar: {
+ fixed: {
+ backgroundColor: background,
+ },
+ drawerPaper: {
+ backgroundColor: `${background} !important`,
+ },
+ },
+ RaMenuItemLink: {
+ root: {
+ color: foreground,
+ '&[aria-current="page"]': {
+ color: `${blue} !important`,
+ },
+ '&[aria-current="page"] .MuiListItemIcon-root': {
+ color: `${blue} !important`,
+ },
+ },
+ active: {
+ color: `${blue} !important`,
+ '& .MuiListItemIcon-root': {
+ color: `${blue} !important`,
+ },
+ },
+ },
+ RaLink: {
+ link: {
+ color: link,
+ },
+ },
+ RaButton: {
+ button: {
+ margin: '0 5px 0 5px',
+ },
+ },
+ RaPaginationActions: {
+ currentPageButton: {
+ border: `2px solid ${blue}`,
+ },
+ button: {
+ backgroundColor: currentLine,
+ minWidth: 48,
+ margin: '0 4px',
+ },
+ },
+ },
+ player: {
+ theme: 'light',
+ stylesheet,
+ },
+}
diff --git a/ui/src/themes/tokyoNight.js b/ui/src/themes/tokyoNight.js
index 07d372a6b..9f6424b77 100644
--- a/ui/src/themes/tokyoNight.js
+++ b/ui/src/themes/tokyoNight.js
@@ -184,16 +184,6 @@ export default {
color: `${foreground} !important`,
},
},
- MuiSwitch: {
- colorSecondary: {
- '&$checked': {
- color: blue,
- },
- '&$checked + $track': {
- backgroundColor: blue,
- },
- },
- },
NDAlbumGridView: {
albumName: {
marginTop: '0.5rem',
diff --git a/ui/src/themes/tokyoNightLight.js b/ui/src/themes/tokyoNightLight.js
index f84cd0be9..a61c0fe87 100644
--- a/ui/src/themes/tokyoNightLight.js
+++ b/ui/src/themes/tokyoNightLight.js
@@ -184,16 +184,6 @@ export default {
color: `${foreground} !important`,
},
},
- MuiSwitch: {
- colorSecondary: {
- '&$checked': {
- color: blue,
- },
- '&$checked + $track': {
- backgroundColor: blue,
- },
- },
- },
NDAlbumGridView: {
albumName: {
marginTop: '0.5rem',
diff --git a/ui/src/themes/useCurrentTheme.js b/ui/src/themes/useCurrentTheme.js
index 2b5d13d13..fbb5e9bc8 100644
--- a/ui/src/themes/useCurrentTheme.js
+++ b/ui/src/themes/useCurrentTheme.js
@@ -59,7 +59,13 @@ const useCurrentTheme = () => {
return useMemo(
() => ({
...theme,
- props: { ...theme.props, MuiUseMediaQuery: { noSsr: true } },
+ props: {
+ ...theme.props,
+ MuiUseMediaQuery: { noSsr: true },
+ MuiPopover: { disableScrollLock: true },
+ // MUI defaults to secondary, which many themes use as a surface color
+ MuiSwitch: { color: 'primary' },
+ },
}),
[theme],
)
diff --git a/ui/src/themes/useCurrentTheme.test.jsx b/ui/src/themes/useCurrentTheme.test.jsx
index 65c3be8c6..6553d9866 100644
--- a/ui/src/themes/useCurrentTheme.test.jsx
+++ b/ui/src/themes/useCurrentTheme.test.jsx
@@ -3,6 +3,10 @@ import { Provider } from 'react-redux'
import { createStore } from 'redux'
import mediaQuery from 'css-mediaquery'
import { renderHook } from '@testing-library/react-hooks'
+import { render, screen } from '@testing-library/react'
+import { createMuiTheme, ThemeProvider } from '@material-ui/core/styles'
+import Switch from '@material-ui/core/Switch'
+import themes from './index'
import useCurrentTheme from './useCurrentTheme'
import { themeReducer } from '../reducers/themeReducer'
import { AUTO_THEME_ID } from '../consts'
@@ -161,4 +165,27 @@ describe('useCurrentTheme', () => {
expect(document.body.style.backgroundColor).toBe('rgb(18, 18, 18)')
})
})
+ describe('switch color', () => {
+ it.each(Object.keys(themes))(
+ 'renders switches with the primary color in %s',
+ (theme) => {
+ const { result } = renderHook(() => useCurrentTheme(), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ })
+ render(
+
+ {}} />
+ ,
+ )
+ const switchBase = screen
+ .getByRole('checkbox')
+ .closest('.MuiSwitch-switchBase')
+ expect(switchBase.classList).toContain('MuiSwitch-colorPrimary')
+ },
+ )
+ })
})
diff --git a/ui/src/transcoding/TranscodingCreate.jsx b/ui/src/transcoding/TranscodingCreate.jsx
index aaf122665..014a94e48 100644
--- a/ui/src/transcoding/TranscodingCreate.jsx
+++ b/ui/src/transcoding/TranscodingCreate.jsx
@@ -8,7 +8,7 @@ import {
useTranslate,
} from 'react-admin'
import { Title } from '../common'
-import { BITRATE_CHOICES } from '../consts'
+import { TRANSCODING_BITRATE_CHOICES } from '../consts'
const TranscodingTitle = () => {
const translate = useTranslate()
@@ -28,7 +28,7 @@ const TranscodingCreate = (props) => (
{
const translate = useTranslate()
@@ -28,7 +28,10 @@ const TranscodingEdit = (props) => {
-
+
diff --git a/ui/src/transcoding/TranscodingList.jsx b/ui/src/transcoding/TranscodingList.jsx
index bca8b49df..d1371c04a 100644
--- a/ui/src/transcoding/TranscodingList.jsx
+++ b/ui/src/transcoding/TranscodingList.jsx
@@ -1,7 +1,8 @@
import React from 'react'
-import { Datagrid, TextField } from 'react-admin'
+import { Datagrid, SelectField, TextField } from 'react-admin'
import { useMediaQuery } from '@material-ui/core'
import { SimpleList, List } from '../common'
+import { TRANSCODING_BITRATE_CHOICES } from '../consts'
import config from '../config'
const TranscodingList = (props) => {
@@ -16,13 +17,22 @@ const TranscodingList = (props) => {
r.name}
secondaryText={(r) => `format: ${r.targetFormat}`}
- tertiaryText={(r) => r.defaultBitRate}
+ tertiaryText={(r) => (
+
+ )}
/>
) : (
-
+
)}
diff --git a/ui/src/transcoding/TranscodingShow.jsx b/ui/src/transcoding/TranscodingShow.jsx
index e132afee5..b7ec2f595 100644
--- a/ui/src/transcoding/TranscodingShow.jsx
+++ b/ui/src/transcoding/TranscodingShow.jsx
@@ -1,7 +1,8 @@
import React from 'react'
-import { Show, SimpleShowLayout, TextField } from 'react-admin'
+import { SelectField, Show, SimpleShowLayout, TextField } from 'react-admin'
import { Title } from '../common'
import { TranscodingNote } from './TranscodingNote'
+import { TRANSCODING_BITRATE_CHOICES } from '../consts'
const TranscodingTitle = ({ record }) => {
return
@@ -16,7 +17,10 @@ const TranscodingShow = (props) => {
-
+
diff --git a/ui/src/user/UserEdit.jsx b/ui/src/user/UserEdit.jsx
index 567709832..feadafff1 100644
--- a/ui/src/user/UserEdit.jsx
+++ b/ui/src/user/UserEdit.jsx
@@ -3,7 +3,6 @@ import { makeStyles } from '@material-ui/core/styles'
import {
TextInput,
BooleanInput,
- DateField,
PasswordInput,
Edit,
required,
@@ -20,10 +19,8 @@ import {
usePermissions,
useRecordContext,
} from 'react-admin'
-import { Avatar, Typography } from '@material-ui/core'
-import { Title, ImageUploadOverlay } from '../common'
-import subsonic from '../subsonic'
-import config from '../config'
+import { Typography } from '@material-ui/core'
+import { ReadOnlyDateField, Title } from '../common'
import DeleteUserButton from './DeleteUserButton'
import { LibrarySelectionField } from './LibrarySelectionField.jsx'
import { validateUserForm } from './userValidation'
@@ -35,17 +32,6 @@ const useStyles = makeStyles({
},
})
-const useAvatarStyles = makeStyles({
- avatarParent: {
- display: 'inline-flex',
- position: 'relative',
- width: '8rem',
- height: '8rem',
- marginBottom: '1em',
- },
- avatar: { width: '100%', height: '100%' },
-})
-
const UserTitle = ({ record }) => {
const translate = useTranslate()
const resourceName = translate('resources.user.name', { smart_count: 1 })
@@ -78,60 +64,6 @@ const NewPasswordInput = ({ formData, ...rest }) => {
) : null
}
-const AvatarField = () => {
- const record = useRecordContext()
- const { permissions } = usePermissions()
- const isAdmin = permissions === 'admin'
- const isMyself = localStorage.getItem('userId') === record?.id
- // Mirrors server canEditAvatar: the flag gates everyone, admins included.
- const canEdit = config.enableUserAvatarUpload && (isAdmin || isMyself)
- const classes = useAvatarStyles()
-
- const handleImageChange = useCallback(
- (hasImage) => {
- if (!isMyself) return
- // Only a cache-buster: the server sends the authoritative ETag, so any changing value works
- if (hasImage) {
- localStorage.setItem('avatarTag', Date.now().toString())
- } else {
- localStorage.removeItem('avatarTag')
- }
- },
- [isMyself],
- )
-
- if (!record?.id) return null
-
- return (
-
- )
-}
-
const UserEdit = (props) => {
const { permissions } = props
const translate = useTranslate()
@@ -185,7 +117,6 @@ const UserEdit = (props) => {
save={save}
validate={validateForm}
>
-
{permissions === 'admin' && (
{
helperText={translate('resources.user.helperTexts.scrobbleFilter')}
/>
-
-
-
-
+
+
+
+
)
diff --git a/ui/src/user/UserEdit.test.jsx b/ui/src/user/UserEdit.test.jsx
index 52e955ed7..837b25ad6 100644
--- a/ui/src/user/UserEdit.test.jsx
+++ b/ui/src/user/UserEdit.test.jsx
@@ -1,8 +1,7 @@
import * as React from 'react'
-import { render, screen, fireEvent } from '@testing-library/react'
+import { render, screen } from '@testing-library/react'
import UserEdit from './UserEdit'
-import config from '../config'
-import { describe, it, expect, vi, afterEach } from 'vitest'
+import { describe, it, expect, vi } from 'vitest'
const defaultUser = {
id: 'user1',
@@ -34,8 +33,6 @@ const hooks = vi.hoisted(() => ({
notify: vi.fn(),
redirect: vi.fn(),
refresh: vi.fn(),
- permissions: 'admin',
- record: null,
}))
// Mock React-Admin completely with simpler implementations
@@ -54,9 +51,6 @@ vi.mock('react-admin', () => ({
BooleanInput: ({ source }) => (
),
- DateField: ({ source }) => (
- Date
- ),
PasswordInput: ({ source }) => (
),
@@ -70,8 +64,7 @@ vi.mock('react-admin', () => ({
useNotify: () => hooks.notify,
useRedirect: () => hooks.redirect,
useRefresh: () => hooks.refresh,
- usePermissions: () => ({ permissions: hooks.permissions }),
- useRecordContext: () => hooks.record,
+ usePermissions: () => ({ permissions: 'admin' }),
useTranslate: () => (key) => key,
}))
@@ -86,29 +79,9 @@ vi.mock('./DeleteUserButton', () => ({
vi.mock('../common', () => ({
Title: ({ subTitle }) => {subTitle}
,
- ImageUploadOverlay: ({ canEdit, messages, onImageChange }) =>
- canEdit ? (
- <>
-
-
- >
- ) : null,
-}))
-
-vi.mock('../subsonic', () => ({
- default: {
- getAvatarUrl: (username) => `/rest/getAvatar?username=${username}`,
- },
+ ReadOnlyDateField: ({ source }) => (
+ Date
+ ),
}))
// Mock Material-UI
@@ -118,7 +91,6 @@ vi.mock('@material-ui/core/styles', () => ({
vi.mock('@material-ui/core', () => ({
Typography: ({ children }) => {children}
,
- Avatar: ({ src, alt }) =>
,
}))
describe('', () => {
@@ -226,75 +198,4 @@ describe('', () => {
expect(hooks.redirect).not.toHaveBeenCalled()
})
})
-
- describe('avatar upload', () => {
- afterEach(() => {
- localStorage.clear()
- hooks.record = null
- hooks.permissions = 'admin'
- })
-
- const renderUserEdit = (
- record,
- { isMyself = false, role = 'user' } = {},
- ) => {
- localStorage.setItem('userId', isMyself ? record.id : 'someone-else')
- hooks.record = record
- hooks.permissions = role
- return render()
- }
-
- it('shows the avatar upload control for the user themselves', () => {
- config.enableUserAvatarUpload = true
- renderUserEdit({ id: 'u1', userName: 'deluan' }, { isMyself: true })
- expect(screen.getByLabelText('message.uploadAvatar')).toBeInTheDocument()
- })
-
- it('stores a new avatar tag when the user uploads their own avatar', () => {
- config.enableUserAvatarUpload = true
- renderUserEdit({ id: 'u1', userName: 'deluan' }, { isMyself: true })
-
- fireEvent.click(screen.getByLabelText('message.uploadAvatar'))
-
- expect(localStorage.getItem('avatarTag')).toBeTruthy()
- })
-
- it('removes the avatar tag when the user removes their own avatar', () => {
- config.enableUserAvatarUpload = true
- localStorage.setItem('avatarTag', 'oldtag')
- renderUserEdit(
- { id: 'u1', userName: 'deluan', uploadedImage: 'u1_deluan.png' },
- { isMyself: true },
- )
-
- fireEvent.click(screen.getByLabelText('message.removeAvatar'))
-
- expect(localStorage.getItem('avatarTag')).toBeNull()
- })
-
- it('does not touch the admin own tag when editing another user', () => {
- config.enableUserAvatarUpload = true
- renderUserEdit(
- { id: 'u1', userName: 'deluan', uploadedImage: 'u1_deluan.png' },
- { isMyself: false, role: 'admin' },
- )
- localStorage.setItem('avatarTag', 'mytag')
-
- fireEvent.click(screen.getByLabelText('message.uploadAvatar'))
- fireEvent.click(screen.getByLabelText('message.removeAvatar'))
-
- expect(localStorage.getItem('avatarTag')).toEqual('mytag')
- })
-
- it('hides the control when the feature is off and the viewer is not an admin', () => {
- config.enableUserAvatarUpload = false
- renderUserEdit(
- { id: 'u1', userName: 'deluan' },
- { isMyself: true, role: 'regular' },
- )
- expect(
- screen.queryByLabelText('message.uploadAvatar'),
- ).not.toBeInTheDocument()
- })
- })
})
diff --git a/ui/src/user/UserList.jsx b/ui/src/user/UserList.jsx
index 4faf31785..7922ca08d 100644
--- a/ui/src/user/UserList.jsx
+++ b/ui/src/user/UserList.jsx
@@ -9,6 +9,8 @@ import {
} from 'react-admin'
import { useMediaQuery } from '@material-ui/core'
import { List, DateField } from '../common'
+import { useDateLocale } from '../i18n/useDateLocale'
+import { formatDateTime } from '../utils/formatters'
const UserFilter = (props) => (
@@ -18,6 +20,7 @@ const UserFilter = (props) => (
const UserList = (props) => {
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
+ const locale = useDateLocale()
return (
{
record.userName}
secondaryText={(record) =>
- record.lastLoginAt && new Date(record.lastLoginAt).toLocaleString()
+ record.lastLoginAt && formatDateTime(record.lastLoginAt, locale)
}
tertiaryText={(record) => (record.isAdmin ? '[admin]️' : '')}
/>
diff --git a/ui/src/utils/formatters.js b/ui/src/utils/formatters.js
index cfcb84b05..f7c59c195 100644
--- a/ui/src/utils/formatters.js
+++ b/ui/src/utils/formatters.js
@@ -95,6 +95,9 @@ export const formatFullDate = (date, locale) => {
return new Date(date).toLocaleDateString(locale, options)
}
+export const formatDateTime = (value, locale) =>
+ new Date(value).toLocaleString(locale)
+
export const formatNumber = (value, locale) => {
if (value === null || value === undefined) return '0'
return value.toLocaleString(locale)
diff --git a/utils/httpclient/httpclient.go b/utils/httpclient/httpclient.go
index 7fb48f36d..b0e7b5681 100644
--- a/utils/httpclient/httpclient.go
+++ b/utils/httpclient/httpclient.go
@@ -3,10 +3,15 @@
package httpclient
import (
+ "context"
+ "net"
"net/http"
+ "net/netip"
+ "net/url"
"time"
"github.com/navidrome/navidrome/consts"
+ "github.com/navidrome/navidrome/utils/netguard"
)
type uaTransport struct {
@@ -33,3 +38,47 @@ func NewTransport(base http.RoundTripper) http.RoundTripper {
func New(timeout time.Duration) *http.Client {
return &http.Client{Timeout: timeout, Transport: NewTransport(nil)}
}
+
+// proxyFunc resolves the proxy for a request; tests replace it.
+var proxyFunc = http.ProxyFromEnvironment
+
+type proxyAddrKey struct{}
+
+// NewExternal is New for URLs from untrusted sources: it refuses to dial private, loopback,
+// link-local and unspecified addresses, except those covered by allowed.
+func NewExternal(timeout time.Duration, allowed ...netip.Prefix) *http.Client {
+ t := http.DefaultTransport.(*http.Transport).Clone()
+ t.Proxy = proxyFunc
+ direct := net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
+ guarded := direct
+ guarded.Control = netguard.DialControl(allowed...)
+ t.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
+ // Exempt the hop to the proxy, which relays the request and is operator config. A URL
+ // aimed at the proxy's own address is not proxied, so it stays guarded.
+ if proxy, ok := ctx.Value(proxyAddrKey{}).(string); ok && proxy == addr {
+ return direct.DialContext(ctx, network, addr)
+ }
+ return guarded.DialContext(ctx, network, addr)
+ }
+ return &http.Client{Timeout: timeout, Transport: NewTransport(&proxyTagger{base: t})}
+}
+
+// proxyTagger records the proxy each request resolves to, so the dialer can tell a hop to the
+// proxy from a dial to the URL's own host.
+type proxyTagger struct{ base http.RoundTripper }
+
+func (p *proxyTagger) RoundTrip(req *http.Request) (*http.Response, error) {
+ if u, err := proxyFunc(req); err == nil && u != nil {
+ req = req.WithContext(context.WithValue(req.Context(), proxyAddrKey{}, proxyAddr(u)))
+ }
+ return p.base.RoundTrip(req)
+}
+
+// proxyAddr mirrors how net/http addresses a proxy connection.
+func proxyAddr(u *url.URL) string {
+ port := u.Port()
+ if port == "" {
+ port = map[string]string{"http": "80", "https": "443", "socks5": "1080", "socks5h": "1080"}[u.Scheme]
+ }
+ return net.JoinHostPort(u.Hostname(), port)
+}
diff --git a/utils/httpclient/httpclient_proxy_internal_test.go b/utils/httpclient/httpclient_proxy_internal_test.go
new file mode 100644
index 000000000..2d542d8fe
--- /dev/null
+++ b/utils/httpclient/httpclient_proxy_internal_test.go
@@ -0,0 +1,68 @@
+package httpclient
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "sync/atomic"
+ "time"
+
+ "github.com/navidrome/navidrome/utils/netguard"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("NewExternal with a proxy", func() {
+ var proxy *httptest.Server
+ var proxied atomic.Int32
+ var proxyURL *url.URL
+
+ BeforeEach(func() {
+ proxied.Store(0)
+ proxy = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ proxied.Add(1)
+ _, _ = w.Write([]byte("via proxy"))
+ }))
+ DeferCleanup(proxy.Close)
+ proxyURL, _ = url.Parse(proxy.URL)
+ prev := proxyFunc
+ DeferCleanup(func() { proxyFunc = prev })
+ })
+
+ It("dials the configured proxy even though it listens on a private address", func() {
+ proxyFunc = func(*http.Request) (*url.URL, error) { return proxyURL, nil }
+
+ resp, err := NewExternal(time.Second).Get("http://navidrome.example.com/cover.jpg")
+ Expect(err).ToNot(HaveOccurred())
+ defer resp.Body.Close()
+ Expect(proxied.Load()).To(Equal(int32(1)))
+ })
+
+ // net/http never proxies loopback targets, so a URL aimed at a loopback proxy is dialed directly.
+ It("refuses a direct dial to the proxy's own address when the request is not proxied", func() {
+ proxyFunc = func(r *http.Request) (*url.URL, error) {
+ if r.URL.Hostname() == "127.0.0.1" {
+ return nil, nil
+ }
+ return proxyURL, nil
+ }
+
+ _, err := NewExternal(time.Second).Get(proxy.URL + "/secret")
+ Expect(err).To(MatchError(netguard.ErrPrivateAddress))
+ Expect(proxied.Load()).To(BeZero())
+ })
+
+ It("still refuses a direct dial to a private address", func() {
+ proxyFunc = func(r *http.Request) (*url.URL, error) {
+ if r.URL.Scheme == "https" {
+ return proxyURL, nil
+ }
+ return nil, nil
+ }
+ target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
+ DeferCleanup(target.Close)
+
+ _, err := NewExternal(time.Second).Get(target.URL)
+ Expect(err).To(MatchError(netguard.ErrPrivateAddress))
+ })
+})
diff --git a/utils/httpclient/httpclient_test.go b/utils/httpclient/httpclient_test.go
index c86b51165..7b1e58797 100644
--- a/utils/httpclient/httpclient_test.go
+++ b/utils/httpclient/httpclient_test.go
@@ -3,10 +3,12 @@ package httpclient_test
import (
"net/http"
"net/http/httptest"
+ "net/netip"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/utils/httpclient"
+ "github.com/navidrome/navidrome/utils/netguard"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -16,6 +18,7 @@ var _ = Describe("httpclient", func() {
var receivedUA string
BeforeEach(func() {
+ receivedUA = ""
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedUA = r.Header.Get("User-Agent")
}))
@@ -48,6 +51,34 @@ var _ = Describe("httpclient", func() {
})
})
+ Describe("NewExternal", func() {
+ It("refuses to connect to a loopback server", func() {
+ c := httpclient.NewExternal(time.Second)
+ _, err := c.Get(server.URL)
+ Expect(err).To(MatchError(netguard.ErrPrivateAddress))
+ Expect(receivedUA).To(BeEmpty())
+ })
+
+ It("refuses a redirect from an allowed host to a private address", func() {
+ redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound)
+ }))
+ DeferCleanup(redirector.Close)
+
+ c := httpclient.NewExternal(time.Second, netip.MustParsePrefix("127.0.0.1/32"))
+ _, err := c.Get(redirector.URL)
+ Expect(err).To(MatchError(netguard.ErrPrivateAddress))
+ })
+
+ It("connects to addresses covered by an allowed prefix and sets the User-Agent", func() {
+ c := httpclient.NewExternal(time.Second, netip.MustParsePrefix("127.0.0.0/8"))
+ resp, err := c.Get(server.URL)
+ Expect(err).ToNot(HaveOccurred())
+ resp.Body.Close()
+ Expect(receivedUA).To(Equal(consts.HTTPUserAgent))
+ })
+ })
+
Describe("NewTransport", func() {
It("uses the default transport when base is nil", func() {
c := &http.Client{Transport: httpclient.NewTransport(nil)}
diff --git a/utils/netguard/netguard.go b/utils/netguard/netguard.go
new file mode 100644
index 000000000..43d80ecc9
--- /dev/null
+++ b/utils/netguard/netguard.go
@@ -0,0 +1,38 @@
+// Package netguard keeps outbound connections driven by untrusted input away from internal addresses.
+package netguard
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "net/netip"
+ "syscall"
+)
+
+var ErrPrivateAddress = errors.New("dial to private/loopback address blocked")
+
+// IsPrivateIP reports whether ip is loopback, private, link-local or unspecified.
+func IsPrivateIP(ip net.IP) bool {
+ return ip.IsLoopback() || ip.IsUnspecified() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()
+}
+
+// DialControl returns a net.Dialer Control hook that rejects private addresses not covered by allowed.
+// It sees the resolved IP, so DNS names, redirects and rebinding cannot get around it.
+func DialControl(allowed ...netip.Prefix) func(network, address string, c syscall.RawConn) error {
+ return func(_, address string, _ syscall.RawConn) error {
+ ap, err := netip.ParseAddrPort(address)
+ if err != nil {
+ return fmt.Errorf("%w: unparseable address %q: %w", ErrPrivateAddress, address, err)
+ }
+ addr := ap.Addr().Unmap()
+ if !IsPrivateIP(addr.AsSlice()) {
+ return nil
+ }
+ for _, p := range allowed {
+ if p.Contains(addr) {
+ return nil
+ }
+ }
+ return fmt.Errorf("%w: %s", ErrPrivateAddress, address)
+ }
+}
diff --git a/scanner/metadata_old/metadata_suite_test.go b/utils/netguard/netguard_suite_test.go
similarity index 66%
rename from scanner/metadata_old/metadata_suite_test.go
rename to utils/netguard/netguard_suite_test.go
index 03ec3c847..7769e0c82 100644
--- a/scanner/metadata_old/metadata_suite_test.go
+++ b/utils/netguard/netguard_suite_test.go
@@ -1,4 +1,4 @@
-package metadata_old
+package netguard_test
import (
"testing"
@@ -9,9 +9,9 @@ import (
. "github.com/onsi/gomega"
)
-func TestMetadata(t *testing.T) {
- tests.Init(t, true)
+func TestNetguard(t *testing.T) {
+ tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
- RunSpecs(t, "Metadata Suite")
+ RunSpecs(t, "Netguard Suite")
}
diff --git a/utils/netguard/netguard_test.go b/utils/netguard/netguard_test.go
new file mode 100644
index 000000000..733204226
--- /dev/null
+++ b/utils/netguard/netguard_test.go
@@ -0,0 +1,65 @@
+package netguard_test
+
+import (
+ "net"
+ "net/netip"
+
+ "github.com/navidrome/navidrome/utils/netguard"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("netguard", func() {
+ DescribeTable("IsPrivateIP",
+ func(addr string, expected bool) {
+ Expect(netguard.IsPrivateIP(net.ParseIP(addr))).To(Equal(expected))
+ },
+ Entry("IPv4 loopback", "127.0.0.1", true),
+ Entry("IPv4 loopback range", "127.0.0.2", true),
+ Entry("IPv6 loopback", "::1", true),
+ Entry("IPv4-mapped IPv6 loopback", "::ffff:127.0.0.1", true),
+ Entry("10.x", "10.1.2.3", true),
+ Entry("172.16.x", "172.16.0.1", true),
+ Entry("192.168.x", "192.168.1.10", true),
+ Entry("IPv6 unique local", "fd00::1", true),
+ Entry("link-local (cloud metadata)", "169.254.169.254", true),
+ Entry("IPv6 link-local", "fe80::1", true),
+ Entry("link-local multicast", "224.0.0.1", true),
+ Entry("IPv4 unspecified", "0.0.0.0", true),
+ Entry("IPv6 unspecified", "::", true),
+ Entry("public IPv4", "93.184.216.34", false),
+ Entry("172.32.x is outside the private block", "172.32.0.1", false),
+ Entry("public IPv6", "2606:4700:4700::1111", false),
+ )
+
+ Describe("DialControl", func() {
+ It("rejects private and loopback addresses", func() {
+ control := netguard.DialControl()
+ for _, addr := range []string{"127.0.0.1:80", "[::1]:443", "169.254.169.254:80", "10.0.0.1:8080", "0.0.0.0:80"} {
+ Expect(control("tcp", addr, nil)).To(MatchError(netguard.ErrPrivateAddress), addr)
+ }
+ })
+
+ It("allows public addresses", func() {
+ control := netguard.DialControl()
+ Expect(control("tcp", "93.184.216.34:443", nil)).To(Succeed())
+ Expect(control("tcp6", "[2606:4700:4700::1111]:443", nil)).To(Succeed())
+ })
+
+ It("allows private addresses covered by an allowed prefix, and only those", func() {
+ control := netguard.DialControl(netip.MustParsePrefix("127.0.0.1/32"))
+ Expect(control("tcp", "127.0.0.1:8080", nil)).To(Succeed())
+ Expect(control("tcp", "127.0.0.2:8080", nil)).To(MatchError(netguard.ErrPrivateAddress))
+ })
+
+ It("matches IPv4-mapped IPv6 addresses against IPv4 prefixes", func() {
+ control := netguard.DialControl(netip.MustParsePrefix("127.0.0.0/8"))
+ Expect(control("tcp", "[::ffff:127.0.0.1]:80", nil)).To(Succeed())
+ })
+
+ It("fails closed when the address is not an IP", func() {
+ Expect(netguard.DialControl()("tcp", "localhost:80", nil)).To(HaveOccurred())
+ Expect(netguard.DialControl()("tcp", "garbage", nil)).To(HaveOccurred())
+ })
+ })
+})
diff --git a/utils/req/req.go b/utils/req/req.go
index 861cca9f7..6a863184c 100644
--- a/utils/req/req.go
+++ b/utils/req/req.go
@@ -178,3 +178,21 @@ func (r *Values) Float64Or(param string, def float64) float64 {
}
return f
}
+
+// IfNoneMatch reports whether the request's If-None-Match asserts etag (unquoted), using RFC 9110 weak comparison.
+func IfNoneMatch(r *http.Request, etag string) bool {
+ header := strings.TrimSpace(r.Header.Get("If-None-Match"))
+ if header == "" {
+ return false
+ }
+ if header == "*" {
+ return true
+ }
+ for tag := range strings.SplitSeq(header, ",") {
+ tag = strings.TrimPrefix(strings.TrimSpace(tag), "W/")
+ if strings.Trim(tag, `"`) == etag {
+ return true
+ }
+ }
+ return false
+}
diff --git a/utils/req/req_test.go b/utils/req/req_test.go
index 5f9de8483..d64f51b02 100644
--- a/utils/req/req_test.go
+++ b/utils/req/req_test.go
@@ -290,3 +290,21 @@ var _ = Describe("Request Helpers", func() {
})
})
})
+
+var _ = Describe("IfNoneMatch", func() {
+ DescribeTable("matches the ETag",
+ func(header string, expected bool) {
+ r := httptest.NewRequest("GET", "/", nil)
+ if header != "" {
+ r.Header.Set("If-None-Match", header)
+ }
+ Expect(req.IfNoneMatch(r, "abc123")).To(Equal(expected))
+ },
+ Entry("absent header", "", false),
+ Entry("exact quoted tag", `"abc123"`, true),
+ Entry("weak tag", `W/"abc123"`, true),
+ Entry("tag in a list", `"other", W/"abc123"`, true),
+ Entry("wildcard", "*", true),
+ Entry("different tag", `"stale"`, false),
+ )
+})
diff --git a/utils/slice/slice.go b/utils/slice/slice.go
index 73537c8f8..e3677e754 100644
--- a/utils/slice/slice.go
+++ b/utils/slice/slice.go
@@ -33,6 +33,23 @@ func Group[T any, K comparable](s []T, keyFunc func(T) K) map[K][]T {
return m
}
+// GroupOrdered is Group with the groups in first-seen order.
+func GroupOrdered[T any, K comparable](s []T, keyFunc func(T) K) [][]T {
+ var groups [][]T
+ index := map[K]int{}
+ for _, item := range s {
+ k := keyFunc(item)
+ i, ok := index[k]
+ if !ok {
+ i = len(groups)
+ index[k] = i
+ groups = append(groups, nil)
+ }
+ groups[i] = append(groups[i], item)
+ }
+ return groups
+}
+
func ToMap[T any, K comparable, V any](s []T, transformFunc func(T) (K, V)) map[K]V {
m := make(map[K]V, len(s))
for _, item := range s {
diff --git a/utils/slice/slice_test.go b/utils/slice/slice_test.go
index 27548d693..506b61b9f 100644
--- a/utils/slice/slice_test.go
+++ b/utils/slice/slice_test.go
@@ -63,6 +63,18 @@ var _ = Describe("Slice Utils", func() {
})
})
+ Describe("GroupOrdered", func() {
+ It("returns nil for an empty input", func() {
+ Expect(slice.GroupOrdered([]int{}, func(v int) int { return v })).To(BeNil())
+ })
+
+ It("keeps groups in first-seen order and items in input order", func() {
+ keyFunc := func(v int) int { return v % 3 }
+ result := slice.GroupOrdered([]int{2, 1, 4, 3, 5, 6, 8}, keyFunc)
+ Expect(result).To(Equal([][]int{{2, 5, 8}, {1, 4}, {3, 6}}))
+ })
+ })
+
Describe("ToMap", func() {
It("returns empty map for an empty input", func() {
transformFunc := func(v int) (int, string) { return v, strconv.Itoa(v) }
diff --git a/utils/str/content_disposition.go b/utils/str/content_disposition.go
new file mode 100644
index 000000000..34ae8a5c6
--- /dev/null
+++ b/utils/str/content_disposition.go
@@ -0,0 +1,80 @@
+package str
+
+import (
+ "cmp"
+ "fmt"
+ "mime"
+ "path"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+
+ "github.com/deluan/sanitize"
+)
+
+const (
+ maxFilenameBytes = 255
+ maxExtensionBytes = 16
+ fallbackFilename = "download"
+)
+
+// ContentDispositionAttachment builds an RFC 6266 attachment header value for a user-controlled filename.
+// Non-ASCII names also get a filename* parameter, which clients prefer over the ASCII fallback.
+func ContentDispositionAttachment(filename string) string {
+ stem, ext := splitFilename(filename)
+ header := fmt.Sprintf("attachment; filename=%q", joinFilename(toASCII(stem), toASCII(ext)))
+ name := joinFilename(stem, ext)
+ if isASCII(name) {
+ return header
+ }
+ // FormatMediaType emits a percent-encoded filename* for non-ASCII values
+ extended := mime.FormatMediaType("attachment", map[string]string{"filename": name})
+ return header + strings.TrimPrefix(extended, "attachment")
+}
+
+func splitFilename(filename string) (stem, ext string) {
+ name := strings.Map(func(r rune) rune {
+ if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) {
+ return -1
+ }
+ return r
+ }, SanitizeFilename(strings.ToValidUTF8(filename, "_")))
+ ext = path.Ext(name)
+ if len(ext) > maxExtensionBytes {
+ ext = ""
+ }
+ stem = strings.TrimSuffix(name, ext)
+ if limit := maxFilenameBytes - len(ext); len(stem) > limit {
+ for limit > 0 && !utf8.RuneStart(stem[limit]) {
+ limit--
+ }
+ stem = stem[:limit]
+ }
+ return stem, ext
+}
+
+func joinFilename(stem, ext string) string {
+ stem = cmp.Or(strings.TrimSpace(stem), fallbackFilename)
+ return cmp.Or(strings.TrimRight(stem+ext, " ."), fallbackFilename)
+}
+
+func toASCII(s string) string {
+ return strings.Map(func(r rune) rune {
+ switch {
+ case r == ',':
+ return '_'
+ case r >= ' ' && r <= '~':
+ return r
+ }
+ return -1
+ }, sanitize.Accents(SanitizeFilename(Clear(s))))
+}
+
+func isASCII(s string) bool {
+ for i := 0; i < len(s); i++ {
+ if s[i] >= utf8.RuneSelf {
+ return false
+ }
+ }
+ return true
+}
diff --git a/utils/str/content_disposition_test.go b/utils/str/content_disposition_test.go
new file mode 100644
index 000000000..944392a4e
--- /dev/null
+++ b/utils/str/content_disposition_test.go
@@ -0,0 +1,120 @@
+package str_test
+
+import (
+ "mime"
+ "regexp"
+ "strings"
+
+ "github.com/navidrome/navidrome/utils/str"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var asciiFilenameRe = regexp.MustCompile(`(?:^|; )filename="([^"]*)"`)
+
+// asciiName returns the quoted filename parameter, the one used by clients that ignore filename*.
+func asciiName(filename string) string {
+ m := asciiFilenameRe.FindAllStringSubmatch(str.ContentDispositionAttachment(filename), -1)
+ ExpectWithOffset(1, m).To(HaveLen(1), "expected exactly one quoted filename parameter")
+ return m[0][1]
+}
+
+// decodedName returns the name an RFC 6266 client picks, preferring filename* when present.
+func decodedName(filename string) string {
+ disp, params, err := mime.ParseMediaType(str.ContentDispositionAttachment(filename))
+ ExpectWithOffset(1, err).ToNot(HaveOccurred())
+ ExpectWithOffset(1, disp).To(Equal("attachment"))
+ return params["filename"]
+}
+
+var _ = Describe("ContentDispositionAttachment", func() {
+ Describe("parameter injection", func() {
+ const attack = `party"; filename="evil.html.m3u`
+
+ It("does not let a quote in the name open a second parameter", func() {
+ Expect(str.ContentDispositionAttachment(attack)).To(Equal(`attachment; filename="party_; filename=_evil.html.m3u"`))
+ })
+
+ It("keeps the header parseable", func() {
+ Expect(decodedName(attack)).To(Equal("party_; filename=_evil.html.m3u"))
+ })
+
+ It("does not let a quote in a non-ASCII name inject either", func() {
+ const utf8Attack = `東京"; filename="evil.html.m3u`
+ header := str.ContentDispositionAttachment(utf8Attack)
+ Expect(strings.Count(header, `"`)).To(Equal(2))
+ Expect(decodedName(utf8Attack)).To(Equal("東京_; filename=_evil.html.m3u"))
+ })
+ })
+
+ Describe("ASCII names", func() {
+ It("sends only the quoted filename, unchanged", func() {
+ Expect(str.ContentDispositionAttachment("My Playlist.m3u")).To(Equal(`attachment; filename="My Playlist.m3u"`))
+ })
+
+ It("keeps leading dots", func() {
+ Expect(asciiName("...And Justice for All.zip")).To(Equal("...And Justice for All.zip"))
+ })
+
+ It("trims surrounding spaces and trailing dots", func() {
+ Expect(asciiName(" Greatest Hits .zip")).To(Equal("Greatest Hits.zip"))
+ Expect(asciiName("Loose End. ")).To(Equal("Loose End"))
+ })
+
+ It("falls back to a placeholder when only dots are left", func() {
+ Expect(str.ContentDispositionAttachment("...")).To(Equal(`attachment; filename="download"`))
+ })
+
+ It("caps a long name whose last dot is not an extension", func() {
+ name := asciiName("a." + strings.Repeat("x", 300))
+ Expect(len(name)).To(BeNumerically("<=", 255))
+ Expect(name).To(HavePrefix("a.xxx"))
+ })
+
+ It("replaces path separators and reserved characters", func() {
+ Expect(asciiName("AC/DC: Live, 1979?.zip")).To(Equal("AC_DC_ Live_ 1979_.zip"))
+ })
+
+ It("drops control characters", func() {
+ Expect(asciiName("line\r\nbreak\x00\t.mp3")).To(Equal("linebreak.mp3"))
+ })
+ })
+
+ Describe("non-ASCII names", func() {
+ It("transliterates accents in the ASCII fallback", func() {
+ Expect(asciiName("Legião Urbana.zip")).To(Equal("Legiao Urbana.zip"))
+ })
+
+ It("converts typographic punctuation in the ASCII fallback", func() {
+ Expect(asciiName("She’s a Woman — Live.mp3")).To(Equal("She's a Woman - Live.mp3"))
+ Expect(asciiName("“Heroes”.mp3")).To(Equal("_Heroes_.mp3"))
+ Expect(decodedName("She’s a Woman.mp3")).To(Equal("She’s a Woman.mp3"))
+ })
+
+ It("keeps the extension when no ASCII letters survive", func() {
+ Expect(asciiName("東京.mp3")).To(Equal("download.mp3"))
+ Expect(asciiName("Кино.m3u")).To(Equal("download.m3u"))
+ Expect(asciiName("東京")).To(Equal("download"))
+ })
+
+ DescribeTable("filename* carries the sanitized UTF-8 name",
+ func(filename, expected string) {
+ Expect(decodedName(filename)).To(Equal(expected))
+ },
+ Entry("accented", "Legião Urbana.zip", "Legião Urbana.zip"),
+ Entry("CJK", "東京.zip", "東京.zip"),
+ Entry("emoji", "🎵 mix.zip", "🎵 mix.zip"),
+ Entry("comma and percent", "Sigur Rós, 100%.zip", "Sigur Rós, 100%.zip"),
+ Entry("path separators", "Sigur Rós/Live: Heima.zip", "Sigur Rós_Live_ Heima.zip"),
+ Entry("control characters", "Sigur Rós\r\n\x00.zip", "Sigur Rós.zip"),
+ Entry("bidi override", "Björk\u202Eexe.mp3", "Björkexe.mp3"),
+ Entry("invalid UTF-8", "Bj\xf6rk Café.mp3", "Bj_rk Café.mp3"),
+ )
+
+ It("caps the name length and keeps the extension", func() {
+ name := decodedName(strings.Repeat("東", 300) + ".zip")
+ Expect(len(name)).To(BeNumerically("<=", 255))
+ Expect(name).To(HaveSuffix("東.zip"))
+ })
+ })
+})