diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go
index fb07332fe..40baa2495 100644
--- a/core/artwork/resolve.go
+++ b/core/artwork/resolve.go
@@ -374,11 +374,8 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
}
}
- tracks := r.ds.Playlist().Tracks(ctx, pl.ID, false)
- if tracks == nil {
- return resolution{}, fmt.Errorf("resolvePlaylist: could not load tracks for playlist %s", pl.ID)
- }
- albumIDs, err := tracks.GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
+ albumIDs, err := r.ds.Playlist().Tracks(ctx, pl.ID, false).
+ GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
if err != nil {
return resolution{}, err
}
diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go
index 2a36531bb..da144d8e2 100644
--- a/core/artwork/resolve_test.go
+++ b/core/artwork/resolve_test.go
@@ -707,16 +707,6 @@ var _ = Describe("resolveItem", func() {
Expect(err).To(HaveOccurred())
Expect(res).To(Equal(resolution{}))
})
-
- It("returns an error when the playlist tracks cannot be loaded", func() {
- plRepo := tests.CreateMockPlaylistRepo()
- plRepo.SetData(model.Playlists{{ID: "pl4", Name: "Playlist"}})
- ds.MockedPlaylist = plRepo
-
- res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl4"})
- Expect(err).To(HaveOccurred())
- Expect(res).To(Equal(resolution{}))
- })
})
})
diff --git a/core/artwork/worker.go b/core/artwork/worker.go
index 4be99f92e..28e51958c 100644
--- a/core/artwork/worker.go
+++ b/core/artwork/worker.go
@@ -4,11 +4,9 @@ import (
"bytes"
"cmp"
"context"
- "fmt"
"io"
"math"
"math/rand/v2"
- "runtime/debug"
"sync"
"time"
@@ -246,7 +244,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary)
trace := &ChainTrace{}
ctx = withTrace(ctx, trace)
- out, got, retryIn := w.safeAcquire(ctx, item)
+ out, got, retryIn := w.proc.acquire(ctx, item)
queue := w.proc.ds.ArtworkQueue()
switch out {
@@ -288,20 +286,6 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
return out, got
}
-// safeAcquire turns a panic into a failed attempt: the drain runs on a bare goroutine, so an
-// unrecovered panic would crash the server, and the still-queued row would crash it again on restart.
-func (w *Worker) safeAcquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) {
- defer func() {
- if r := recover(); r != nil {
- log.Error(ctx, "Artwork: Panic while processing item", "kind", item.ItemKind, "id", item.ItemID,
- "imageType", item.ImageType, "attempts", item.Attempts, "panic", r, "stack", string(debug.Stack()))
- traceStage(ctx, "panic", fmt.Errorf("%v", r))
- out, got, retryIn = outcomeFailed, nil, 0
- }
- }()
- return w.proc.acquire(ctx, item)
-}
-
// recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item
// that never resolved has no row to update, and creating one would settle it absent.
func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) {
diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go
index 80ca68bc3..a6c07b763 100644
--- a/core/artwork/worker_test.go
+++ b/core/artwork/worker_test.go
@@ -142,18 +142,6 @@ func (v *visibilityPlaylistRepo) Get(ctx context.Context, id string) (*model.Pla
return v.MockPlaylistRepo.Get(ctx, id)
}
-type panickingAlbumRepo struct {
- *tests.MockAlbumRepo
- panicID string
-}
-
-func (r *panickingAlbumRepo) Get(ctx context.Context, id string) (*model.Album, error) {
- if id == r.panicID {
- panic("boom")
- }
- return r.MockAlbumRepo.Get(ctx, id)
-}
-
func adminUserRepo() *tests.MockedUserRepo {
repo := tests.CreateMockUserRepo()
Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed())
@@ -290,36 +278,6 @@ var _ = Describe("Worker", func() {
Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent")
})
- It("fails an item that panics, without stopping the rest of the batch", func() {
- folderRepo.result = []model.Folder{{
- Path: "tests/fixtures/artist/an-album",
- ImageFiles: []string{"cover.jpg"},
- }}
- albums := tests.CreateMockAlbumRepo()
- albums.SetData(model.Albums{
- {ID: "alboom", Name: "Album", FolderIDs: []string{"f1"}},
- {ID: "alok", Name: "Album", FolderIDs: []string{"f1"}},
- })
- ds.MockedAlbum = &panickingAlbumRepo{MockAlbumRepo: albums, panicID: "alboom"}
- Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alboom"})).To(Succeed())
- Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alok"})).To(Succeed())
-
- n, err := w.drain(ctx, 1)
- Expect(err).ToNot(HaveOccurred())
- Expect(n).To(Equal(2))
-
- it := findQueued(queueRepo, "al", "alboom")
- Expect(it).ToNot(BeNil(), "a panicking item must be rescheduled, not dropped")
- Expect(it.Attempts).To(Equal(1))
- Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
- Expect(it.Trace).To(ContainSubstring("boom"))
-
- Expect(findQueued(queueRepo, "al", "alok")).To(BeNil())
- ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alok", model.ImageTypePrimary)
- Expect(err).ToNot(HaveOccurred())
- Expect(ia.Source).To(Equal("folder"))
- })
-
It("reschedules past the provider's requested delay when it exceeds the backoff", func() {
conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})
diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json
index 238919259..1a0c1ab8a 100644
--- a/resources/i18n/pt-br.json
+++ b/resources/i18n/pt-br.json
@@ -660,6 +660,11 @@
"options": {
"theme": "Tema",
"language": "Língua",
+ "dateFormat": "Formato de data",
+ "dateFormats": {
+ "language": "Igual à língua",
+ "browser": "Igual ao navegador"
+ },
"defaultView": "Tela inicial",
"desktop_notifications": "Notificações",
"lastfmScrobbling": "Enviar scrobbles para Last.fm",
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/AlbumTableView.jsx b/ui/src/album/AlbumTableView.jsx
index 051d5988e..46a86c6bc 100644
--- a/ui/src/album/AlbumTableView.jsx
+++ b/ui/src/album/AlbumTableView.jsx
@@ -3,7 +3,6 @@ import {
Datagrid,
DatagridBody,
DatagridRow,
- DateField,
NumberField,
TextField,
FunctionField,
@@ -13,6 +12,7 @@ import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
import { makeStyles } from '@material-ui/core/styles'
import { useDrag } from 'react-dnd'
import {
+ DateField,
ArtistLinkField,
ArtworkAvatar,
DurationField,
diff --git a/ui/src/common/DateField.jsx b/ui/src/common/DateField.jsx
index dac9cff08..8aab34ae1 100644
--- a/ui/src/common/DateField.jsx
+++ b/ui/src/common/DateField.jsx
@@ -1,12 +1,12 @@
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 record = useRecordContext(props)
const locale = useDateLocale()
- const value = record?.[source]
+ const value = record?.[props.source]
if (!isDateSet(value)) return null
return
}
diff --git a/ui/src/common/DateField.test.jsx b/ui/src/common/DateField.test.jsx
index 47d56756b..7494b7887 100644
--- a/ui/src/common/DateField.test.jsx
+++ b/ui/src/common/DateField.test.jsx
@@ -1,6 +1,7 @@
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) => ({
@@ -29,4 +30,13 @@ describe('', () => {
)
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/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/i18n/en.json b/ui/src/i18n/en.json
index f694ea75f..8f086f516 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -677,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
index c9298a473..f02402f29 100644
--- a/ui/src/i18n/useDateLocale.js
+++ b/ui/src/i18n/useDateLocale.js
@@ -1,5 +1,8 @@
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 = []) => {
@@ -10,5 +13,10 @@ const resolveDateLocale = (locale, browserLocales = []) => {
)
}
-export const useDateLocale = () =>
- resolveDateLocale(useLocale(), navigator.languages)
+// 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
index c0ea5ab40..16707a084 100644
--- a/ui/src/i18n/useDateLocale.test.js
+++ b/ui/src/i18n/useDateLocale.test.js
@@ -9,6 +9,7 @@ vi.mock('react-admin', () => ({
describe('useDateLocale', () => {
beforeEach(() => {
vi.clearAllMocks()
+ localStorage.removeItem('dateFormat')
})
const renderWith = async (locale, browserLocales) => {
@@ -42,4 +43,10 @@ describe('useDateLocale', () => {
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/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/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx
index e1695e980..56f0bf532 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,
@@ -95,7 +95,6 @@ export const ToggleField = ({ resource, source }) => {
return (
diff --git a/ui/src/playlist/PlaylistList.test.jsx b/ui/src/playlist/PlaylistList.test.jsx
index c05833166..6c714b827 100644
--- a/ui/src/playlist/PlaylistList.test.jsx
+++ b/ui/src/playlist/PlaylistList.test.jsx
@@ -2,7 +2,6 @@ import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { TestContext } from 'ra-test'
-import { RecordContextProvider } from 'react-admin'
import { PlaylistLove, ToggleField, ToggleAutoImport } from './PlaylistList'
vi.mock('../config', () => ({
@@ -15,7 +14,6 @@ vi.mock('../common', () => ({
{record?.starred ? 'starred' : 'not-starred'}
),
- isWritable: (ownerId) => ownerId === 'me',
}))
describe('', () => {
@@ -57,28 +55,3 @@ describe('playlist toggles without a record', () => {
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/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/themes/dracula.js b/ui/src/themes/dracula.js
index 45559c3af..2e4ae38e5 100644
--- a/ui/src/themes/dracula.js
+++ b/ui/src/themes/dracula.js
@@ -185,6 +185,16 @@ 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 3e2955dcd..0f4cbd7c4 100644
--- a/ui/src/themes/gruvboxDark.js
+++ b/ui/src/themes/gruvboxDark.js
@@ -121,6 +121,16 @@ 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/tokyoNight.js b/ui/src/themes/tokyoNight.js
index 9f6424b77..07d372a6b 100644
--- a/ui/src/themes/tokyoNight.js
+++ b/ui/src/themes/tokyoNight.js
@@ -184,6 +184,16 @@ 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 a61c0fe87..f84cd0be9 100644
--- a/ui/src/themes/tokyoNightLight.js
+++ b/ui/src/themes/tokyoNightLight.js
@@ -184,6 +184,16 @@ 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 fbb5e9bc8..4ccefe820 100644
--- a/ui/src/themes/useCurrentTheme.js
+++ b/ui/src/themes/useCurrentTheme.js
@@ -63,8 +63,6 @@ const useCurrentTheme = () => {
...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 6553d9866..65c3be8c6 100644
--- a/ui/src/themes/useCurrentTheme.test.jsx
+++ b/ui/src/themes/useCurrentTheme.test.jsx
@@ -3,10 +3,6 @@ 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'
@@ -165,27 +161,4 @@ 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')
- },
- )
- })
})