Merge remote-tracking branch 'origin/master' into pr5949

# Conflicts:
#	cmd/wire_gen.go
This commit is contained in:
Jiho Andrew Lee 2026-10-06 09:34:16 +09:00
commit bd6a3fb16e
No known key found for this signature in database
GPG key ID: 399C495F019E884F
58 changed files with 1732 additions and 543 deletions

View file

@ -32,6 +32,8 @@ 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,

View file

@ -369,11 +369,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",
@ -403,7 +414,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": {

View file

@ -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 (
<Typography
variant="h6"
gutterBottom
className={spaced ? classes.spaced : undefined}
>
{translate(label)}
</Typography>
)
}
SectionTitle.propTypes = {
label: PropTypes.string.isRequired,
spaced: PropTypes.bool,
}
const LibraryCreate = (props) => {
const translate = useTranslate()
@ -73,9 +101,12 @@ const LibraryCreate = (props) => {
return (
<Create title={<Title subTitle={title} />} {...props}>
<SimpleForm save={save} variant={'outlined'}>
<SectionTitle label="resources.library.sections.basic" />
<TextInput source="name" validate={[required()]} />
<TextInput source="path" validate={[required()]} fullWidth />
<BooleanInput source="defaultNewUsers" />
<SectionTitle label="resources.library.sections.pid" spaced />
<PIDInputs />
</SimpleForm>
</Create>
)

View file

@ -1,9 +1,11 @@
import React, { useCallback } from 'react'
import React, { useCallback, useState } from 'react'
import PropTypes from 'prop-types'
import {
Edit,
FormWithRedirect,
TextInput,
BooleanInput,
Confirm,
required,
SaveButton,
useTranslate,
@ -22,6 +24,9 @@ import {
ReadOnlySizeField,
Title,
} from '../common'
import config from '../config'
import { PIDInputs } from './PIDInput'
import { pidConfigChanged } from './pidPresets'
const useStyles = makeStyles({
toolbar: {
@ -53,8 +58,131 @@ const CustomToolbar = ({ showDelete, ...props }) => (
</Toolbar>
)
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 (
<form
onSubmit={(event) => {
event.preventDefault()
submit()
}}
>
<Box p="1em" maxWidth="800px">
<Box display="flex">
<Box flex={1} mr="1em">
{/* Basic Information */}
<Typography variant="h6" gutterBottom>
{translate('resources.library.sections.basic')}
</Typography>
<TextInput
source="name"
label={translate('resources.library.fields.name')}
validate={[required()]}
variant="outlined"
/>
<TextInput
source="path"
label={translate('resources.library.fields.path')}
validate={[required()]}
fullWidth
variant="outlined"
InputProps={{ readOnly: !canEditPath }} // Disable editing path for library 1
/>
<BooleanInput
source="defaultNewUsers"
label={translate('resources.library.fields.defaultNewUsers')}
variant="outlined"
/>
<Box mt="2em" />
<Typography variant="h6" gutterBottom>
{translate('resources.library.sections.pid')}
</Typography>
<PIDInputs />
<Box mt="2em" />
{/* Statistics - Two Column Layout */}
<Typography variant="h6" gutterBottom>
{translate('resources.library.sections.statistics')}
</Typography>
<Box
display="grid"
gridTemplateColumns="1fr 1fr"
gridColumnGap="1em"
>
<ReadOnlyNumberField source="totalSongs" {...readOnlyProps} />
<ReadOnlyNumberField source="totalAlbums" {...readOnlyProps} />
<ReadOnlyNumberField source="totalArtists" {...readOnlyProps} />
<ReadOnlySizeField source="totalSize" {...readOnlyProps} />
<ReadOnlyDurationField
source="totalDuration"
{...readOnlyProps}
/>
<ReadOnlyNumberField
source="totalMissingFiles"
{...readOnlyProps}
/>
<Box gridColumn="1 / -1">
<ReadOnlyDateField source="lastScanAt" {...readOnlyProps} />
</Box>
<ReadOnlyDateField source="updatedAt" {...readOnlyProps} />
<ReadOnlyDateField source="createdAt" {...readOnlyProps} />
</Box>
</Box>
</Box>
</Box>
<CustomToolbar
handleSubmitWithRedirect={submit}
pristine={formProps.pristine}
saving={formProps.saving}
record={formProps.record}
showDelete={canDelete}
/>
<Confirm
isOpen={confirmOpen}
loading={formProps.saving}
title="resources.library.messages.pidChangeTitle"
content="resources.library.messages.pidChangeConfirm"
onConfirm={handleConfirm}
onClose={() => setConfirmOpen(false)}
/>
</form>
)
}
LibraryEditForm.propTypes = {
formProps: PropTypes.object.isRequired,
canEditPath: PropTypes.bool,
canDelete: PropTypes.bool,
}
const LibraryEdit = (props) => {
const [mutate] = useMutation()
const notify = useNotify()
const redirect = useRedirect()
@ -93,91 +221,11 @@ const LibraryEdit = (props) => {
{...props}
save={save}
render={(formProps) => (
<form onSubmit={formProps.handleSubmit}>
<Box p="1em" maxWidth="800px">
<Box display="flex">
<Box flex={1} mr="1em">
{/* Basic Information */}
<Typography variant="h6" gutterBottom>
{translate('resources.library.sections.basic')}
</Typography>
<TextInput
source="name"
label={translate('resources.library.fields.name')}
validate={[required()]}
variant="outlined"
/>
<TextInput
source="path"
label={translate('resources.library.fields.path')}
validate={[required()]}
fullWidth
variant="outlined"
InputProps={{ readOnly: !canEditPath }} // Disable editing path for library 1
/>
<BooleanInput
source="defaultNewUsers"
label={translate(
'resources.library.fields.defaultNewUsers',
)}
variant="outlined"
/>
<Box mt="2em" />
{/* Statistics - Two Column Layout */}
<Typography variant="h6" gutterBottom>
{translate('resources.library.sections.statistics')}
</Typography>
<Box
display="grid"
gridTemplateColumns="1fr 1fr"
gridColumnGap="1em"
>
<ReadOnlyNumberField
source="totalSongs"
{...readOnlyProps}
/>
<ReadOnlyNumberField
source="totalAlbums"
{...readOnlyProps}
/>
<ReadOnlyNumberField
source="totalArtists"
{...readOnlyProps}
/>
<ReadOnlySizeField source="totalSize" {...readOnlyProps} />
<ReadOnlyDurationField
source="totalDuration"
{...readOnlyProps}
/>
<ReadOnlyNumberField
source="totalMissingFiles"
{...readOnlyProps}
/>
<Box gridColumn="1 / -1">
<ReadOnlyDateField
source="lastScanAt"
{...readOnlyProps}
/>
</Box>
<ReadOnlyDateField source="updatedAt" {...readOnlyProps} />
<ReadOnlyDateField source="createdAt" {...readOnlyProps} />
</Box>
</Box>
</Box>
</Box>
<CustomToolbar
handleSubmitWithRedirect={formProps.handleSubmitWithRedirect}
pristine={formProps.pristine}
saving={formProps.saving}
record={formProps.record}
showDelete={canDelete}
/>
</form>
<LibraryEditForm
formProps={formProps}
canEditPath={canEditPath}
canDelete={canDelete}
/>
)}
/>
</Edit>

View file

@ -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(
<TestContext>
<SaveContextProvider value={saveContext}>
<RecordContextProvider value={record}>
<FormWithRedirect
record={record}
save={save}
render={(formProps) => (
<LibraryEditForm
formProps={formProps}
canEditPath
canDelete={false}
/>
)}
/>
</RecordContextProvider>
</SaveContextProvider>
</TestContext>,
)
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()
})
})

114
ui/src/library/PIDInput.jsx Normal file
View file

@ -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 (
<>
<TextField
id={`${source}-mode`}
select
fullWidth
variant="outlined"
margin="dense"
label={label}
value={mode}
onChange={handleModeChange}
>
{choices.map((choice) => (
<MenuItem key={choice.id} value={choice.id}>
{choice.name}
</MenuItem>
))}
</TextField>
{mode === PID_CUSTOM && (
<>
<TextInput
source={source}
label={translate('resources.library.pid.spec')}
validate={[required()]}
fullWidth
variant="outlined"
helperText={false}
/>
<FormHelperText className={classes.help}>
{translate('resources.library.pid.help')}{' '}
<Link href={PID_DOCS_URL} target="_blank" rel="noopener noreferrer">
{translate('resources.library.pid.docs')}
</Link>
</FormHelperText>
</>
)}
</>
)
}
PIDInput.propTypes = {
source: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
globalValue: PropTypes.string,
allowFolder: PropTypes.bool,
}
export const PIDInputs = () => {
const translate = useTranslate()
return (
<>
<PIDInput
source="pidAlbum"
label={translate('resources.library.fields.pidAlbum')}
globalValue={config.pidAlbum}
allowFolder
/>
<PIDInput
source="pidTrack"
label={translate('resources.library.fields.pidTrack')}
globalValue={config.pidTrack}
/>
</>
)
}

View file

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

View file

@ -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)
})
})