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') - }, - ) - }) })