({
+ type: SET_SELECTED_LIBRARIES,
+ data: libraryIds,
+})
+
+export const setUserLibraries = (libraries) => ({
+ type: SET_USER_LIBRARIES,
+ data: libraries,
+})
diff --git a/ui/src/album/AlbumInfo.jsx b/ui/src/album/AlbumInfo.jsx
index 453dbb167..e71cd3d33 100644
--- a/ui/src/album/AlbumInfo.jsx
+++ b/ui/src/album/AlbumInfo.jsx
@@ -38,6 +38,7 @@ const AlbumInfo = (props) => {
const record = useRecordContext(props)
const data = {
album: ,
+ libraryName: ,
albumArtist: (
),
diff --git a/ui/src/common/LibrarySelector.jsx b/ui/src/common/LibrarySelector.jsx
new file mode 100644
index 000000000..1e89d3ec6
--- /dev/null
+++ b/ui/src/common/LibrarySelector.jsx
@@ -0,0 +1,221 @@
+import React, { useState, useEffect, useCallback } from 'react'
+import { useDispatch, useSelector } from 'react-redux'
+import { useDataProvider, useTranslate, useRefresh } from 'react-admin'
+import {
+ Box,
+ Chip,
+ ClickAwayListener,
+ FormControl,
+ FormGroup,
+ FormControlLabel,
+ Checkbox,
+ Typography,
+ Paper,
+ Popper,
+ makeStyles,
+} from '@material-ui/core'
+import { ExpandMore, ExpandLess, LibraryMusic } from '@material-ui/icons'
+import { setSelectedLibraries, setUserLibraries } from '../actions'
+import { useRefreshOnEvents } from './useRefreshOnEvents'
+
+const useStyles = makeStyles((theme) => ({
+ root: {
+ marginTop: theme.spacing(3),
+ marginBottom: theme.spacing(3),
+ paddingLeft: theme.spacing(2),
+ paddingRight: theme.spacing(2),
+ display: 'flex',
+ justifyContent: 'center',
+ },
+ chip: {
+ borderRadius: theme.spacing(1),
+ height: theme.spacing(4.8),
+ fontSize: '1rem',
+ fontWeight: 'normal',
+ minWidth: '210px',
+ justifyContent: 'flex-start',
+ paddingLeft: theme.spacing(1),
+ paddingRight: theme.spacing(1),
+ marginTop: theme.spacing(0.1),
+ '& .MuiChip-label': {
+ paddingLeft: theme.spacing(2),
+ paddingRight: theme.spacing(1),
+ },
+ '& .MuiChip-icon': {
+ fontSize: '1.2rem',
+ marginLeft: theme.spacing(0.5),
+ },
+ },
+ popper: {
+ zIndex: 1300,
+ },
+ paper: {
+ padding: theme.spacing(2),
+ marginTop: theme.spacing(1),
+ minWidth: 300,
+ maxWidth: 400,
+ },
+ headerContainer: {
+ display: 'flex',
+ alignItems: 'center',
+ marginBottom: 0,
+ },
+ masterCheckbox: {
+ padding: '7px',
+ marginLeft: '-9px',
+ marginRight: 0,
+ },
+}))
+
+const LibrarySelector = () => {
+ const classes = useStyles()
+ const dispatch = useDispatch()
+ const dataProvider = useDataProvider()
+ const translate = useTranslate()
+ const refresh = useRefresh()
+ const [anchorEl, setAnchorEl] = useState(null)
+ const [open, setOpen] = useState(false)
+
+ const { userLibraries, selectedLibraries } = useSelector(
+ (state) => state.library,
+ )
+
+ // Load user's libraries when component mounts
+ const loadUserLibraries = useCallback(async () => {
+ const userId = localStorage.getItem('userId')
+ if (userId) {
+ try {
+ const { data } = await dataProvider.getOne('user', { id: userId })
+ const libraries = data.libraries || []
+ dispatch(setUserLibraries(libraries))
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ 'Could not load user libraries (this may be expected for non-admin users):',
+ error,
+ )
+ }
+ }
+ }, [dataProvider, dispatch])
+
+ // Initial load
+ useEffect(() => {
+ loadUserLibraries()
+ }, [loadUserLibraries])
+
+ // Reload user libraries when library changes occur
+ useRefreshOnEvents({
+ events: ['library', 'user'],
+ onRefresh: loadUserLibraries,
+ })
+
+ // Don't render if user has no libraries or only has one library
+ if (!userLibraries.length || userLibraries.length === 1) {
+ return null
+ }
+
+ const handleToggle = (event) => {
+ setAnchorEl(event.currentTarget)
+ const wasOpen = open
+ setOpen(!open)
+ // Refresh data when closing the dropdown
+ if (wasOpen) {
+ refresh()
+ }
+ }
+
+ const handleClose = () => {
+ setOpen(false)
+ refresh()
+ }
+
+ const handleLibraryToggle = (libraryId) => {
+ const newSelection = selectedLibraries.includes(libraryId)
+ ? selectedLibraries.filter((id) => id !== libraryId)
+ : [...selectedLibraries, libraryId]
+
+ dispatch(setSelectedLibraries(newSelection))
+ }
+
+ const handleMasterCheckboxChange = () => {
+ if (isAllSelected) {
+ dispatch(setSelectedLibraries([]))
+ } else {
+ const allIds = userLibraries.map((lib) => lib.id)
+ dispatch(setSelectedLibraries(allIds))
+ }
+ }
+
+ const selectedCount = selectedLibraries.length
+ const totalCount = userLibraries.length
+ const isAllSelected = selectedCount === totalCount
+ const isNoneSelected = selectedCount === 0
+ const isIndeterminate = selectedCount > 0 && selectedCount < totalCount
+
+ const displayText = isNoneSelected
+ ? translate('menu.librarySelector.none') + ` (0 of ${totalCount})`
+ : isAllSelected
+ ? translate('menu.librarySelector.allLibraries', { count: totalCount })
+ : translate('menu.librarySelector.multipleLibraries', {
+ selected: selectedCount,
+ total: totalCount,
+ })
+
+ return (
+
+ }
+ label={displayText}
+ onClick={handleToggle}
+ onDelete={open ? handleToggle : undefined}
+ deleteIcon={open ? : }
+ variant="outlined"
+ className={classes.chip}
+ />
+
+
+
+
+
+
+
+ {translate('menu.librarySelector.selectLibraries')}:
+
+
+
+
+
+ {userLibraries.map((library) => (
+ handleLibraryToggle(library.id)}
+ size="small"
+ />
+ }
+ label={library.name}
+ />
+ ))}
+
+
+
+
+
+
+ )
+}
+
+export default LibrarySelector
diff --git a/ui/src/common/LibrarySelector.test.jsx b/ui/src/common/LibrarySelector.test.jsx
new file mode 100644
index 000000000..13b607887
--- /dev/null
+++ b/ui/src/common/LibrarySelector.test.jsx
@@ -0,0 +1,517 @@
+import React from 'react'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import LibrarySelector from './LibrarySelector'
+
+// Mock dependencies
+const mockDispatch = vi.fn()
+const mockDataProvider = {
+ getOne: vi.fn(),
+}
+const mockIdentity = { username: 'testuser' }
+const mockRefresh = vi.fn()
+const mockTranslate = vi.fn((key, options = {}) => {
+ const translations = {
+ 'menu.librarySelector.allLibraries': `All Libraries (${options.count || 0})`,
+ 'menu.librarySelector.multipleLibraries': `${options.selected || 0} of ${options.total || 0} Libraries`,
+ 'menu.librarySelector.none': 'None',
+ 'menu.librarySelector.selectLibraries': 'Select Libraries',
+ }
+ return translations[key] || key
+})
+
+vi.mock('react-redux', () => ({
+ useDispatch: () => mockDispatch,
+ useSelector: vi.fn(),
+}))
+
+vi.mock('react-admin', () => ({
+ useDataProvider: () => mockDataProvider,
+ useGetIdentity: () => ({ identity: mockIdentity }),
+ useTranslate: () => mockTranslate,
+ useRefresh: () => mockRefresh,
+}))
+
+// Mock Material-UI components
+vi.mock('@material-ui/core', () => ({
+ Box: ({ children, className, ...props }) => (
+
+ {children}
+
+ ),
+ Chip: ({ label, onClick, onDelete, deleteIcon, icon, ...props }) => (
+
+ ),
+ ClickAwayListener: ({ children, onClickAway }) => (
+
+ {children}
+
+ ),
+ Collapse: ({ children, in: inProp }) =>
+ inProp ? {children}
: null,
+ FormControl: ({ children }) => {children}
,
+ FormGroup: ({ children }) => {children}
,
+ FormControlLabel: ({ control, label }) => (
+
+ ),
+ Checkbox: ({
+ checked,
+ indeterminate,
+ onChange,
+ size,
+ className,
+ ...props
+ }) => (
+ {
+ if (el) el.indeterminate = indeterminate
+ }}
+ onChange={onChange}
+ className={className}
+ {...props}
+ />
+ ),
+ Typography: ({ children, variant, ...props }) => (
+ {children}
+ ),
+ Paper: ({ children, className }) => (
+ {children}
+ ),
+ Popper: ({ open, children, anchorEl, placement, className }) =>
+ open ? (
+
+ {children}
+
+ ) : null,
+ makeStyles: (styles) => () => {
+ if (typeof styles === 'function') {
+ return styles({
+ spacing: (value) => `${value * 8}px`,
+ palette: { divider: '#ccc' },
+ shape: { borderRadius: 4 },
+ })
+ }
+ return styles
+ },
+}))
+
+vi.mock('@material-ui/icons', () => ({
+ ExpandMore: () => ▼,
+ ExpandLess: () => ▲,
+ LibraryMusic: () => 🎵,
+}))
+
+// Mock actions
+vi.mock('../actions', () => ({
+ setSelectedLibraries: (libraries) => ({
+ type: 'SET_SELECTED_LIBRARIES',
+ data: libraries,
+ }),
+ setUserLibraries: (libraries) => ({
+ type: 'SET_USER_LIBRARIES',
+ data: libraries,
+ }),
+}))
+
+describe('LibrarySelector', () => {
+ const mockLibraries = [
+ { id: '1', name: 'Music Library', path: '/music' },
+ { id: '2', name: 'Podcasts', path: '/podcasts' },
+ { id: '3', name: 'Audiobooks', path: '/audiobooks' },
+ ]
+
+ const defaultState = {
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1'],
+ }
+
+ let mockUseSelector
+
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ const { useSelector } = await import('react-redux')
+ mockUseSelector = vi.mocked(useSelector)
+ mockDataProvider.getOne.mockResolvedValue({
+ data: { libraries: mockLibraries },
+ })
+ // Setup localStorage mock
+ Object.defineProperty(window, 'localStorage', {
+ value: {
+ getItem: vi.fn(() => null), // Default to null to prevent API calls
+ setItem: vi.fn(),
+ },
+ writable: true,
+ })
+ })
+
+ const renderLibrarySelector = (selectorState = defaultState) => {
+ mockUseSelector.mockImplementation((selector) =>
+ selector({ library: selectorState }),
+ )
+
+ return render()
+ }
+
+ describe('when user has no libraries', () => {
+ it('should not render anything', () => {
+ const { container } = renderLibrarySelector({
+ userLibraries: [],
+ selectedLibraries: [],
+ })
+ expect(container.firstChild).toBeNull()
+ })
+ })
+
+ describe('when user has only one library', () => {
+ it('should not render anything', () => {
+ const singleLibrary = [mockLibraries[0]]
+ const { container } = renderLibrarySelector({
+ userLibraries: singleLibrary,
+ selectedLibraries: ['1'],
+ })
+ expect(container.firstChild).toBeNull()
+ })
+ })
+
+ describe('when user has multiple libraries', () => {
+ it('should render the chip with correct label when one library is selected', () => {
+ renderLibrarySelector()
+
+ expect(screen.getByRole('button')).toBeInTheDocument()
+ expect(screen.getByText('1 of 3 Libraries')).toBeInTheDocument()
+ expect(screen.getByTestId('library-music')).toBeInTheDocument()
+ expect(screen.getByTestId('expand-more')).toBeInTheDocument()
+ })
+
+ it('should render the chip with "All Libraries" when all libraries are selected', () => {
+ renderLibrarySelector({
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1', '2', '3'],
+ })
+
+ expect(screen.getByText('All Libraries (3)')).toBeInTheDocument()
+ })
+
+ it('should render the chip with "None" when no libraries are selected', () => {
+ renderLibrarySelector({
+ userLibraries: mockLibraries,
+ selectedLibraries: [],
+ })
+
+ expect(screen.getByText('None (0 of 3)')).toBeInTheDocument()
+ })
+
+ it('should show expand less icon when dropdown is open', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector()
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ expect(screen.getByTestId('expand-less')).toBeInTheDocument()
+ })
+
+ it('should open dropdown when chip is clicked', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector()
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ expect(screen.getByTestId('popper')).toBeInTheDocument()
+ expect(screen.getByText('Select Libraries:')).toBeInTheDocument()
+ })
+
+ it('should display all library names in dropdown', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector()
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ expect(screen.getByText('Music Library')).toBeInTheDocument()
+ expect(screen.getByText('Podcasts')).toBeInTheDocument()
+ expect(screen.getByText('Audiobooks')).toBeInTheDocument()
+ })
+
+ it('should not display library paths', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector()
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ expect(screen.queryByText('/music')).not.toBeInTheDocument()
+ expect(screen.queryByText('/podcasts')).not.toBeInTheDocument()
+ expect(screen.queryByText('/audiobooks')).not.toBeInTheDocument()
+ })
+
+ describe('master checkbox', () => {
+ it('should be checked when all libraries are selected', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector({
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1', '2', '3'],
+ })
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ const masterCheckbox = checkboxes[0] // First checkbox is the master checkbox
+ expect(masterCheckbox.checked).toBe(true)
+ expect(masterCheckbox.indeterminate).toBe(false)
+ })
+
+ it('should be unchecked when no libraries are selected', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector({
+ userLibraries: mockLibraries,
+ selectedLibraries: [],
+ })
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ const masterCheckbox = checkboxes[0]
+ expect(masterCheckbox.checked).toBe(false)
+ expect(masterCheckbox.indeterminate).toBe(false)
+ })
+
+ it('should be indeterminate when some libraries are selected', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector({
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1', '2'],
+ })
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ const masterCheckbox = checkboxes[0]
+ expect(masterCheckbox.checked).toBe(false)
+ expect(masterCheckbox.indeterminate).toBe(true)
+ })
+
+ it('should select all libraries when clicked and none are selected', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector({
+ userLibraries: mockLibraries,
+ selectedLibraries: [],
+ })
+
+ // Clear the dispatch mock after initial mount (it sets user libraries)
+ mockDispatch.mockClear()
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ const masterCheckbox = checkboxes[0]
+
+ // Use fireEvent.click to trigger the onChange event
+ fireEvent.click(masterCheckbox)
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: 'SET_SELECTED_LIBRARIES',
+ data: ['1', '2', '3'],
+ })
+ })
+
+ it('should deselect all libraries when clicked and all are selected', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector({
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1', '2', '3'],
+ })
+
+ // Clear the dispatch mock after initial mount (it sets user libraries)
+ mockDispatch.mockClear()
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ const masterCheckbox = checkboxes[0]
+
+ fireEvent.click(masterCheckbox)
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: 'SET_SELECTED_LIBRARIES',
+ data: [],
+ })
+ })
+
+ it('should select all libraries when clicked and some are selected', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector({
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1'],
+ })
+
+ // Clear the dispatch mock after initial mount (it sets user libraries)
+ mockDispatch.mockClear()
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ const masterCheckbox = checkboxes[0]
+
+ fireEvent.click(masterCheckbox)
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: 'SET_SELECTED_LIBRARIES',
+ data: ['1', '2', '3'],
+ })
+ })
+ })
+
+ describe('individual library checkboxes', () => {
+ it('should show correct checked state for each library', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector({
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1', '3'],
+ })
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ // Skip master checkbox (index 0)
+ expect(checkboxes[1].checked).toBe(true) // Music Library
+ expect(checkboxes[2].checked).toBe(false) // Podcasts
+ expect(checkboxes[3].checked).toBe(true) // Audiobooks
+ })
+
+ it('should toggle library selection when individual checkbox is clicked', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector()
+
+ // Clear the dispatch mock after initial mount (it sets user libraries)
+ mockDispatch.mockClear()
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ const podcastsCheckbox = checkboxes[2] // Podcasts checkbox
+
+ fireEvent.click(podcastsCheckbox)
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: 'SET_SELECTED_LIBRARIES',
+ data: ['1', '2'],
+ })
+ })
+
+ it('should remove library from selection when clicking checked library', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector({
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1', '2'],
+ })
+
+ // Clear the dispatch mock after initial mount (it sets user libraries)
+ mockDispatch.mockClear()
+
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ const musicCheckbox = checkboxes[1] // Music Library checkbox
+
+ fireEvent.click(musicCheckbox)
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: 'SET_SELECTED_LIBRARIES',
+ data: ['2'],
+ })
+ })
+ })
+
+ it('should close dropdown when clicking away', async () => {
+ const user = userEvent.setup()
+ renderLibrarySelector()
+
+ // Open dropdown
+ const chipButton = screen.getByRole('button')
+ await user.click(chipButton)
+
+ expect(screen.getByTestId('popper')).toBeInTheDocument()
+
+ // Click away
+ const clickAwayListener = screen.getByTestId('click-away-listener')
+ fireEvent.mouseDown(clickAwayListener)
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('popper')).not.toBeInTheDocument()
+ })
+
+ // Should trigger refresh when closing
+ expect(mockRefresh).toHaveBeenCalledTimes(1)
+ })
+
+ it('should load user libraries on mount', async () => {
+ // Override localStorage mock to return a userId for this test
+ window.localStorage.getItem.mockReturnValue('user123')
+
+ mockDataProvider.getOne.mockResolvedValue({
+ data: { libraries: mockLibraries },
+ })
+
+ renderLibrarySelector({ userLibraries: [], selectedLibraries: [] })
+
+ await waitFor(() => {
+ expect(mockDataProvider.getOne).toHaveBeenCalledWith('user', {
+ id: 'user123',
+ })
+ })
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: 'SET_USER_LIBRARIES',
+ data: mockLibraries,
+ })
+ })
+
+ it('should handle API error gracefully', async () => {
+ // Override localStorage mock to return a userId for this test
+ window.localStorage.getItem.mockReturnValue('user123')
+
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ mockDataProvider.getOne.mockRejectedValue(new Error('API Error'))
+
+ renderLibrarySelector({ userLibraries: [], selectedLibraries: [] })
+
+ await waitFor(() => {
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Could not load user libraries (this may be expected for non-admin users):',
+ expect.any(Error),
+ )
+ })
+
+ consoleSpy.mockRestore()
+ })
+
+ it('should not load libraries when userId is not available', () => {
+ window.localStorage.getItem.mockReturnValue(null)
+
+ renderLibrarySelector({ userLibraries: [], selectedLibraries: [] })
+
+ expect(mockDataProvider.getOne).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/ui/src/common/SelectLibraryInput.jsx b/ui/src/common/SelectLibraryInput.jsx
new file mode 100644
index 000000000..0ac9783f5
--- /dev/null
+++ b/ui/src/common/SelectLibraryInput.jsx
@@ -0,0 +1,228 @@
+import React, { useState, useEffect, useMemo } from 'react'
+import Checkbox from '@material-ui/core/Checkbox'
+import CheckBoxIcon from '@material-ui/icons/CheckBox'
+import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'
+import {
+ List,
+ ListItem,
+ ListItemIcon,
+ ListItemText,
+ Typography,
+ Box,
+} from '@material-ui/core'
+import { useGetList, useTranslate } from 'react-admin'
+import PropTypes from 'prop-types'
+import { makeStyles } from '@material-ui/core'
+
+const useStyles = makeStyles((theme) => ({
+ root: {
+ width: '960px',
+ maxWidth: '100%',
+ },
+ headerContainer: {
+ display: 'flex',
+ alignItems: 'center',
+ marginBottom: theme.spacing(1),
+ paddingLeft: theme.spacing(1),
+ },
+ masterCheckbox: {
+ padding: '7px',
+ marginLeft: '-9px',
+ marginRight: theme.spacing(1),
+ },
+ libraryList: {
+ height: '120px',
+ overflow: 'auto',
+ border: `1px solid ${theme.palette.divider}`,
+ borderRadius: theme.shape.borderRadius,
+ backgroundColor: theme.palette.background.paper,
+ },
+ listItem: {
+ paddingTop: 0,
+ paddingBottom: 0,
+ },
+ emptyMessage: {
+ padding: theme.spacing(2),
+ textAlign: 'center',
+ color: theme.palette.text.secondary,
+ },
+}))
+
+const EmptyLibraryMessage = () => {
+ const classes = useStyles()
+
+ return (
+
+ No libraries available
+
+ )
+}
+
+const LibraryListItem = ({ library, isSelected, onToggle }) => {
+ const classes = useStyles()
+
+ return (
+ onToggle(library)}
+ dense
+ >
+
+ }
+ checkedIcon={}
+ checked={isSelected}
+ tabIndex={-1}
+ disableRipple
+ />
+
+
+
+ )
+}
+
+export const SelectLibraryInput = ({
+ onChange,
+ value = [],
+ isNewUser = false,
+}) => {
+ const classes = useStyles()
+ const translate = useTranslate()
+ const [selectedLibraryIds, setSelectedLibraryIds] = useState([])
+ const [hasInitialized, setHasInitialized] = useState(false)
+
+ const { ids, data, isLoading } = useGetList(
+ 'library',
+ { page: 1, perPage: -1 },
+ { field: 'name', order: 'ASC' },
+ )
+
+ const options = useMemo(
+ () => (ids && ids.map((id) => data[id])) || [],
+ [ids, data],
+ )
+
+ // Reset initialization state when isNewUser changes
+ useEffect(() => {
+ if (isNewUser) {
+ setHasInitialized(false)
+ }
+ }, [isNewUser])
+
+ // Pre-select default libraries for new users
+ useEffect(() => {
+ if (
+ isNewUser &&
+ !isLoading &&
+ options.length > 0 &&
+ !hasInitialized &&
+ Array.isArray(value) &&
+ value.length === 0
+ ) {
+ const defaultLibraryIds = options
+ .filter((lib) => lib.defaultNewUsers)
+ .map((lib) => lib.id)
+
+ if (defaultLibraryIds.length > 0) {
+ setSelectedLibraryIds(defaultLibraryIds)
+ onChange(defaultLibraryIds)
+ }
+
+ setHasInitialized(true)
+ }
+ }, [isNewUser, isLoading, options, hasInitialized, value, onChange])
+
+ // Update selectedLibraryIds when value prop changes (for editing mode and pre-selection)
+ useEffect(() => {
+ // For new users, only sync from value prop if it has actual data
+ // This prevents empty initial state from overriding our pre-selection
+ if (isNewUser && Array.isArray(value) && value.length === 0) {
+ return
+ }
+
+ if (Array.isArray(value)) {
+ const libraryIds = value.map((item) =>
+ typeof item === 'object' ? item.id : item,
+ )
+ setSelectedLibraryIds(libraryIds)
+ } else if (value.length === 0) {
+ // Handle case where value is explicitly set to empty array (for existing users)
+ setSelectedLibraryIds([])
+ }
+ }, [value, isNewUser, hasInitialized])
+
+ const isLibrarySelected = (library) => selectedLibraryIds.includes(library.id)
+
+ const handleLibraryToggle = (library) => {
+ const isSelected = selectedLibraryIds.includes(library.id)
+ let newSelection
+
+ if (isSelected) {
+ newSelection = selectedLibraryIds.filter((id) => id !== library.id)
+ } else {
+ newSelection = [...selectedLibraryIds, library.id]
+ }
+
+ setSelectedLibraryIds(newSelection)
+ onChange(newSelection)
+ }
+
+ const handleMasterCheckboxChange = () => {
+ const isAllSelected = selectedLibraryIds.length === options.length
+ const newSelection = isAllSelected ? [] : options.map((lib) => lib.id)
+
+ setSelectedLibraryIds(newSelection)
+ onChange(newSelection)
+ }
+
+ const selectedCount = selectedLibraryIds.length
+ const totalCount = options.length
+ const isAllSelected = selectedCount === totalCount && totalCount > 0
+ const isIndeterminate = selectedCount > 0 && selectedCount < totalCount
+
+ return (
+
+ {options.length > 1 && (
+
+
+
+ {translate('resources.user.message.selectAllLibraries')}
+
+
+ )}
+
+ {options.length === 0 ? (
+
+ ) : (
+ options.map((library) => (
+
+ ))
+ )}
+
+
+ )
+}
+
+SelectLibraryInput.propTypes = {
+ onChange: PropTypes.func.isRequired,
+ value: PropTypes.array,
+ isNewUser: PropTypes.bool,
+}
+
+LibraryListItem.propTypes = {
+ library: PropTypes.object.isRequired,
+ isSelected: PropTypes.bool.isRequired,
+ onToggle: PropTypes.func.isRequired,
+}
diff --git a/ui/src/common/SelectLibraryInput.test.jsx b/ui/src/common/SelectLibraryInput.test.jsx
new file mode 100644
index 000000000..8a7e56d3e
--- /dev/null
+++ b/ui/src/common/SelectLibraryInput.test.jsx
@@ -0,0 +1,458 @@
+import * as React from 'react'
+import { render, screen, fireEvent, cleanup } from '@testing-library/react'
+import { SelectLibraryInput } from './SelectLibraryInput'
+import { useGetList } from 'react-admin'
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+
+// Mock Material-UI components
+vi.mock('@material-ui/core', () => ({
+ List: ({ children }) => {children}
,
+ ListItem: ({ children, button, onClick, dense, className }) => (
+
+ ),
+ ListItemIcon: ({ children }) => {children},
+ ListItemText: ({ primary }) => {primary},
+ Typography: ({ children, variant }) => {children},
+ Box: ({ children, className }) => {children}
,
+ Checkbox: ({
+ checked,
+ indeterminate,
+ onChange,
+ size,
+ className,
+ ...props
+ }) => (
+ {
+ if (el) el.indeterminate = indeterminate
+ }}
+ onChange={onChange}
+ className={className}
+ {...props}
+ />
+ ),
+ makeStyles: () => () => ({}),
+}))
+
+// Mock Material-UI icons
+vi.mock('@material-ui/icons', () => ({
+ CheckBox: () => ☑,
+ CheckBoxOutlineBlank: () => ☐,
+}))
+
+// Mock the react-admin hook
+vi.mock('react-admin', () => ({
+ useGetList: vi.fn(),
+ useTranslate: vi.fn(() => (key) => key), // Simple translation mock
+}))
+
+describe('', () => {
+ const mockOnChange = vi.fn()
+
+ beforeEach(() => {
+ // Reset the mock before each test
+ mockOnChange.mockClear()
+ })
+
+ afterEach(cleanup)
+
+ it('should render empty message when no libraries available', () => {
+ // Mock empty library response
+ useGetList.mockReturnValue({
+ ids: [],
+ data: {},
+ })
+
+ render()
+ expect(screen.getByText('No libraries available')).not.toBeNull()
+ })
+
+ it('should render libraries when available', () => {
+ // Mock libraries
+ const mockLibraries = {
+ 1: { id: '1', name: 'Library 1' },
+ 2: { id: '2', name: 'Library 2' },
+ }
+ useGetList.mockReturnValue({
+ ids: ['1', '2'],
+ data: mockLibraries,
+ })
+
+ render()
+ expect(screen.getByText('Library 1')).not.toBeNull()
+ expect(screen.getByText('Library 2')).not.toBeNull()
+ })
+
+ it('should toggle selection when a library is clicked', () => {
+ // Mock libraries
+ const mockLibraries = {
+ 1: { id: '1', name: 'Library 1' },
+ 2: { id: '2', name: 'Library 2' },
+ }
+
+ // Test selecting an item
+ useGetList.mockReturnValue({
+ ids: ['1', '2'],
+ data: mockLibraries,
+ })
+ render()
+
+ // Find the library buttons by their text content
+ const library1Button = screen.getByText('Library 1').closest('button')
+ fireEvent.click(library1Button)
+ expect(mockOnChange).toHaveBeenCalledWith(['1'])
+
+ // Clean up to avoid DOM duplication
+ cleanup()
+ mockOnChange.mockClear()
+
+ // Test deselecting an item
+ useGetList.mockReturnValue({
+ ids: ['1', '2'],
+ data: mockLibraries,
+ })
+ render()
+
+ // Find the library button again and click to deselect
+ const library1ButtonDeselect = screen
+ .getByText('Library 1')
+ .closest('button')
+ fireEvent.click(library1ButtonDeselect)
+ expect(mockOnChange).toHaveBeenCalledWith([])
+ })
+
+ it('should correctly initialize with provided values', () => {
+ // Mock libraries
+ const mockLibraries = {
+ 1: { id: '1', name: 'Library 1' },
+ 2: { id: '2', name: 'Library 2' },
+ }
+ useGetList.mockReturnValue({
+ ids: ['1', '2'],
+ data: mockLibraries,
+ })
+
+ // Initial value as array of IDs
+ render()
+
+ // Check that checkbox for Library 1 is checked
+ const checkboxes = screen.getAllByRole('checkbox')
+ // With master checkbox, individual checkboxes start at index 1
+ expect(checkboxes[1].checked).toBe(true) // Library 1
+ expect(checkboxes[2].checked).toBe(false) // Library 2
+ })
+
+ it('should handle value as array of objects', () => {
+ // Mock libraries
+ const mockLibraries = {
+ 1: { id: '1', name: 'Library 1' },
+ 2: { id: '2', name: 'Library 2' },
+ }
+ useGetList.mockReturnValue({
+ ids: ['1', '2'],
+ data: mockLibraries,
+ })
+
+ // Initial value as array of objects with id property
+ render()
+
+ // Check that checkbox for Library 2 is checked
+ const checkboxes = screen.getAllByRole('checkbox')
+ // With master checkbox, index shifts by 1
+ expect(checkboxes[1].checked).toBe(false) // Library 1
+ expect(checkboxes[2].checked).toBe(true) // Library 2
+ })
+
+ it('should render master checkbox when there are multiple libraries', () => {
+ // Mock libraries
+ const mockLibraries = {
+ 1: { id: '1', name: 'Library 1' },
+ 2: { id: '2', name: 'Library 2' },
+ }
+ useGetList.mockReturnValue({
+ ids: ['1', '2'],
+ data: mockLibraries,
+ })
+
+ render()
+
+ // Should render master checkbox plus individual checkboxes
+ const checkboxes = screen.getAllByRole('checkbox')
+ expect(checkboxes).toHaveLength(3) // 1 master + 2 individual
+ expect(
+ screen.getByText('resources.user.message.selectAllLibraries'),
+ ).not.toBeNull()
+ })
+
+ it('should not render master checkbox when there is only one library', () => {
+ // Mock single library
+ const mockLibraries = {
+ 1: { id: '1', name: 'Library 1' },
+ }
+ useGetList.mockReturnValue({
+ ids: ['1'],
+ data: mockLibraries,
+ })
+
+ render()
+
+ // Should render only individual checkbox
+ const checkboxes = screen.getAllByRole('checkbox')
+ expect(checkboxes).toHaveLength(1) // Only 1 individual checkbox
+ })
+
+ it('should handle master checkbox selection and deselection', () => {
+ // Mock libraries
+ const mockLibraries = {
+ 1: { id: '1', name: 'Library 1' },
+ 2: { id: '2', name: 'Library 2' },
+ }
+ useGetList.mockReturnValue({
+ ids: ['1', '2'],
+ data: mockLibraries,
+ })
+
+ render()
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ const masterCheckbox = checkboxes[0] // Master is first
+
+ // Click master checkbox to select all
+ fireEvent.click(masterCheckbox)
+ expect(mockOnChange).toHaveBeenCalledWith(['1', '2'])
+
+ // Clean up and test deselect all
+ cleanup()
+ mockOnChange.mockClear()
+
+ render()
+ const checkboxes2 = screen.getAllByRole('checkbox')
+ const masterCheckbox2 = checkboxes2[0]
+
+ // Click master checkbox to deselect all
+ fireEvent.click(masterCheckbox2)
+ expect(mockOnChange).toHaveBeenCalledWith([])
+ })
+
+ it('should show master checkbox as indeterminate when some libraries are selected', () => {
+ // Mock libraries
+ const mockLibraries = {
+ 1: { id: '1', name: 'Library 1' },
+ 2: { id: '2', name: 'Library 2' },
+ }
+ useGetList.mockReturnValue({
+ ids: ['1', '2'],
+ data: mockLibraries,
+ })
+
+ render()
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ const masterCheckbox = checkboxes[0] // Master is first
+
+ // Master checkbox should not be checked when only some libraries are selected
+ expect(masterCheckbox.checked).toBe(false)
+ // Note: Testing indeterminate property directly through JSDOM can be unreliable
+ // The important behavior is that it's not checked when only some are selected
+ })
+
+ describe('New User Default Library Selection', () => {
+ // Helper function to create mock libraries with configurable default settings
+ const createMockLibraries = (libraryConfigs) => {
+ const libraries = {}
+ const ids = []
+
+ libraryConfigs.forEach(({ id, name, defaultNewUsers }) => {
+ libraries[id] = {
+ id,
+ name,
+ ...(defaultNewUsers !== undefined && { defaultNewUsers }),
+ }
+ ids.push(id)
+ })
+
+ return { libraries, ids }
+ }
+
+ // Helper function to setup useGetList mock
+ const setupMockLibraries = (libraryConfigs, isLoading = false) => {
+ const { libraries, ids } = createMockLibraries(libraryConfigs)
+ useGetList.mockReturnValue({
+ ids,
+ data: libraries,
+ isLoading,
+ })
+ return { libraries, ids }
+ }
+
+ beforeEach(() => {
+ mockOnChange.mockClear()
+ })
+
+ it('should pre-select default libraries for new users', () => {
+ setupMockLibraries([
+ { id: '1', name: 'Library 1', defaultNewUsers: true },
+ { id: '2', name: 'Library 2', defaultNewUsers: false },
+ { id: '3', name: 'Library 3', defaultNewUsers: true },
+ ])
+
+ render(
+ ,
+ )
+
+ expect(mockOnChange).toHaveBeenCalledWith(['1', '3'])
+ })
+
+ it('should not pre-select default libraries if new user already has values', () => {
+ setupMockLibraries([
+ { id: '1', name: 'Library 1', defaultNewUsers: true },
+ { id: '2', name: 'Library 2', defaultNewUsers: false },
+ ])
+
+ render(
+ ,
+ )
+
+ expect(mockOnChange).not.toHaveBeenCalled()
+ })
+
+ it('should not pre-select libraries while data is still loading', () => {
+ setupMockLibraries(
+ [{ id: '1', name: 'Library 1', defaultNewUsers: true }],
+ true,
+ ) // isLoading = true
+
+ render(
+ ,
+ )
+
+ expect(mockOnChange).not.toHaveBeenCalled()
+ })
+
+ it('should not pre-select anything if no libraries have defaultNewUsers flag', () => {
+ setupMockLibraries([
+ { id: '1', name: 'Library 1', defaultNewUsers: false },
+ { id: '2', name: 'Library 2', defaultNewUsers: false },
+ ])
+
+ render(
+ ,
+ )
+
+ expect(mockOnChange).not.toHaveBeenCalled()
+ })
+
+ it('should reset initialization state when isNewUser prop changes', () => {
+ setupMockLibraries([
+ { id: '1', name: 'Library 1', defaultNewUsers: true },
+ ])
+
+ const { rerender } = render(
+ ,
+ )
+
+ expect(mockOnChange).not.toHaveBeenCalled()
+
+ // Change to new user
+ rerender(
+ ,
+ )
+
+ expect(mockOnChange).toHaveBeenCalledWith(['1'])
+ })
+
+ it('should not override pre-selection when value prop is empty for new users', () => {
+ setupMockLibraries([
+ { id: '1', name: 'Library 1', defaultNewUsers: true },
+ { id: '2', name: 'Library 2', defaultNewUsers: false },
+ ])
+
+ const { rerender } = render(
+ ,
+ )
+
+ expect(mockOnChange).toHaveBeenCalledWith(['1'])
+ mockOnChange.mockClear()
+
+ // Re-render with empty value prop (simulating form state update)
+ rerender(
+ ,
+ )
+
+ expect(mockOnChange).not.toHaveBeenCalled()
+ })
+
+ it('should sync from value prop for existing users even when empty', () => {
+ setupMockLibraries([
+ { id: '1', name: 'Library 1', defaultNewUsers: true },
+ ])
+
+ render(
+ ,
+ )
+
+ // Check that no libraries are selected (checkboxes should be unchecked)
+ const checkboxes = screen.getAllByRole('checkbox')
+ // Only one checkbox since there's only one library and no master checkbox for single library
+ expect(checkboxes[0].checked).toBe(false)
+ })
+
+ it('should handle libraries with missing defaultNewUsers property', () => {
+ setupMockLibraries([
+ { id: '1', name: 'Library 1', defaultNewUsers: true },
+ { id: '2', name: 'Library 2' }, // Missing defaultNewUsers property
+ { id: '3', name: 'Library 3', defaultNewUsers: false },
+ ])
+
+ render(
+ ,
+ )
+
+ expect(mockOnChange).toHaveBeenCalledWith(['1'])
+ })
+ })
+})
diff --git a/ui/src/common/SongInfo.jsx b/ui/src/common/SongInfo.jsx
index 9b9ca18cd..1b1a014f1 100644
--- a/ui/src/common/SongInfo.jsx
+++ b/ui/src/common/SongInfo.jsx
@@ -59,6 +59,7 @@ export const SongInfo = (props) => {
]
const data = {
path: ,
+ libraryName: ,
album: (
),
diff --git a/ui/src/common/index.js b/ui/src/common/index.js
index 1a43047c1..f64d4fe0c 100644
--- a/ui/src/common/index.js
+++ b/ui/src/common/index.js
@@ -27,6 +27,7 @@ export * from './useAlbumsPerPage'
export * from './useGetHandleArtistClick'
export * from './useInterval'
export * from './useResourceRefresh'
+export * from './useRefreshOnEvents'
export * from './useToggleLove'
export * from './useTraceUpdate'
export * from './Writable'
diff --git a/ui/src/common/useLibrarySelection.js b/ui/src/common/useLibrarySelection.js
new file mode 100644
index 000000000..c5d84a61f
--- /dev/null
+++ b/ui/src/common/useLibrarySelection.js
@@ -0,0 +1,44 @@
+import { useSelector } from 'react-redux'
+
+/**
+ * Hook to get the currently selected library IDs
+ * Returns an array of library IDs that should be used for filtering data
+ * If no libraries are selected (empty array), returns all user accessible libraries
+ */
+export const useSelectedLibraries = () => {
+ const { userLibraries, selectedLibraries } = useSelector(
+ (state) => state.library,
+ )
+
+ // If no specific selection, default to all accessible libraries
+ if (selectedLibraries.length === 0 && userLibraries.length > 0) {
+ return userLibraries.map((lib) => lib.id)
+ }
+
+ return selectedLibraries
+}
+
+/**
+ * Hook to get library filter parameters for data provider queries
+ * Returns an object that can be spread into query parameters
+ */
+export const useLibraryFilter = () => {
+ const selectedLibraryIds = useSelectedLibraries()
+
+ // If user has access to only one library or no specific selection, no filter needed
+ if (selectedLibraryIds.length <= 1) {
+ return {}
+ }
+
+ return {
+ libraryIds: selectedLibraryIds,
+ }
+}
+
+/**
+ * Hook to check if a specific library is currently selected
+ */
+export const useIsLibrarySelected = (libraryId) => {
+ const selectedLibraryIds = useSelectedLibraries()
+ return selectedLibraryIds.includes(libraryId)
+}
diff --git a/ui/src/common/useLibrarySelection.test.js b/ui/src/common/useLibrarySelection.test.js
new file mode 100644
index 000000000..30f109dc6
--- /dev/null
+++ b/ui/src/common/useLibrarySelection.test.js
@@ -0,0 +1,204 @@
+import { renderHook } from '@testing-library/react-hooks'
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import {
+ useSelectedLibraries,
+ useLibraryFilter,
+ useIsLibrarySelected,
+} from './useLibrarySelection'
+
+// Mock dependencies
+vi.mock('react-redux', () => ({
+ useSelector: vi.fn(),
+}))
+
+describe('Library Selection Hooks', () => {
+ const mockLibraries = [
+ { id: '1', name: 'Music Library' },
+ { id: '2', name: 'Podcasts' },
+ { id: '3', name: 'Audiobooks' },
+ ]
+
+ let mockUseSelector
+
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ const { useSelector } = await import('react-redux')
+ mockUseSelector = vi.mocked(useSelector)
+ })
+
+ const setupSelector = (
+ userLibraries = mockLibraries,
+ selectedLibraries = [],
+ ) => {
+ mockUseSelector.mockImplementation((selector) =>
+ selector({
+ library: {
+ userLibraries,
+ selectedLibraries,
+ },
+ }),
+ )
+ }
+
+ describe('useSelectedLibraries', () => {
+ it('should return selected library IDs when libraries are explicitly selected', async () => {
+ setupSelector(mockLibraries, ['1', '2'])
+
+ const { result } = renderHook(() => useSelectedLibraries())
+
+ expect(result.current).toEqual(['1', '2'])
+ })
+
+ it('should return all user library IDs when no libraries are selected and user has libraries', async () => {
+ setupSelector(mockLibraries, [])
+
+ const { result } = renderHook(() => useSelectedLibraries())
+
+ expect(result.current).toEqual(['1', '2', '3'])
+ })
+
+ it('should return empty array when no libraries are selected and user has no libraries', async () => {
+ setupSelector([], [])
+
+ const { result } = renderHook(() => useSelectedLibraries())
+
+ expect(result.current).toEqual([])
+ })
+
+ it('should return selected libraries even if they are all user libraries', async () => {
+ setupSelector(mockLibraries, ['1', '2', '3'])
+
+ const { result } = renderHook(() => useSelectedLibraries())
+
+ expect(result.current).toEqual(['1', '2', '3'])
+ })
+
+ it('should return single selected library', async () => {
+ setupSelector(mockLibraries, ['2'])
+
+ const { result } = renderHook(() => useSelectedLibraries())
+
+ expect(result.current).toEqual(['2'])
+ })
+ })
+
+ describe('useLibraryFilter', () => {
+ it('should return empty object when user has only one library', async () => {
+ setupSelector([mockLibraries[0]], ['1'])
+
+ const { result } = renderHook(() => useLibraryFilter())
+
+ expect(result.current).toEqual({})
+ })
+
+ it('should return empty object when no libraries are selected (defaults to all)', async () => {
+ setupSelector([mockLibraries[0]], [])
+
+ const { result } = renderHook(() => useLibraryFilter())
+
+ expect(result.current).toEqual({})
+ })
+
+ it('should return libraryIds filter when multiple libraries are available and some are selected', async () => {
+ setupSelector(mockLibraries, ['1', '2'])
+
+ const { result } = renderHook(() => useLibraryFilter())
+
+ expect(result.current).toEqual({
+ libraryIds: ['1', '2'],
+ })
+ })
+
+ it('should return libraryIds filter when multiple libraries are available and all are selected', async () => {
+ setupSelector(mockLibraries, ['1', '2', '3'])
+
+ const { result } = renderHook(() => useLibraryFilter())
+
+ expect(result.current).toEqual({
+ libraryIds: ['1', '2', '3'],
+ })
+ })
+
+ it('should return empty object when user has no libraries', async () => {
+ setupSelector([], [])
+
+ const { result } = renderHook(() => useLibraryFilter())
+
+ expect(result.current).toEqual({})
+ })
+
+ it('should return libraryIds filter for default selection when multiple libraries available', async () => {
+ setupSelector(mockLibraries, []) // No explicit selection, should default to all
+
+ const { result } = renderHook(() => useLibraryFilter())
+
+ expect(result.current).toEqual({
+ libraryIds: ['1', '2', '3'],
+ })
+ })
+ })
+
+ describe('useIsLibrarySelected', () => {
+ it('should return true when library is explicitly selected', async () => {
+ setupSelector(mockLibraries, ['1', '3'])
+
+ const { result: result1 } = renderHook(() => useIsLibrarySelected('1'))
+ const { result: result2 } = renderHook(() => useIsLibrarySelected('3'))
+
+ expect(result1.current).toBe(true)
+ expect(result2.current).toBe(true)
+ })
+
+ it('should return false when library is not explicitly selected', async () => {
+ setupSelector(mockLibraries, ['1', '3'])
+
+ const { result } = renderHook(() => useIsLibrarySelected('2'))
+
+ expect(result.current).toBe(false)
+ })
+
+ it('should return true when no explicit selection (defaults to all) and library exists', async () => {
+ setupSelector(mockLibraries, [])
+
+ const { result: result1 } = renderHook(() => useIsLibrarySelected('1'))
+ const { result: result2 } = renderHook(() => useIsLibrarySelected('2'))
+ const { result: result3 } = renderHook(() => useIsLibrarySelected('3'))
+
+ expect(result1.current).toBe(true)
+ expect(result2.current).toBe(true)
+ expect(result3.current).toBe(true)
+ })
+
+ it('should return false when library does not exist in user libraries', async () => {
+ setupSelector(mockLibraries, [])
+
+ const { result } = renderHook(() => useIsLibrarySelected('999'))
+
+ expect(result.current).toBe(false)
+ })
+
+ it('should return false when user has no libraries', async () => {
+ setupSelector([], [])
+
+ const { result } = renderHook(() => useIsLibrarySelected('1'))
+
+ expect(result.current).toBe(false)
+ })
+
+ it('should handle undefined libraryId', async () => {
+ setupSelector(mockLibraries, ['1'])
+
+ const { result } = renderHook(() => useIsLibrarySelected(undefined))
+
+ expect(result.current).toBe(false)
+ })
+
+ it('should handle null libraryId', async () => {
+ setupSelector(mockLibraries, ['1'])
+
+ const { result } = renderHook(() => useIsLibrarySelected(null))
+
+ expect(result.current).toBe(false)
+ })
+ })
+})
diff --git a/ui/src/common/useRefreshOnEvents.jsx b/ui/src/common/useRefreshOnEvents.jsx
new file mode 100644
index 000000000..b5f1b1ede
--- /dev/null
+++ b/ui/src/common/useRefreshOnEvents.jsx
@@ -0,0 +1,109 @@
+import { useEffect, useState } from 'react'
+import { useSelector } from 'react-redux'
+
+/**
+ * A reusable hook for triggering custom reload logic when specific SSE events occur.
+ *
+ * This hook is ideal when:
+ * - Your component displays derived/related data that isn't directly managed by react-admin
+ * - You need custom loading logic that goes beyond simple dataProvider.getMany() calls
+ * - Your data comes from non-standard endpoints or requires special processing
+ * - You want to reload parent/related resources when child resources change
+ *
+ * @param {Object} options - Configuration options
+ * @param {Array} options.events - Array of event types to listen for (e.g., ['library', 'user', '*'])
+ * @param {Function} options.onRefresh - Async function to call when events occur.
+ * Should be wrapped in useCallback with appropriate dependencies to avoid unnecessary re-renders.
+ *
+ * @example
+ * // Example 1: LibrarySelector - Reload user data when library changes
+ * const loadUserLibraries = useCallback(async () => {
+ * const userId = localStorage.getItem('userId')
+ * if (userId) {
+ * const { data } = await dataProvider.getOne('user', { id: userId })
+ * dispatch(setUserLibraries(data.libraries || []))
+ * }
+ * }, [dataProvider, dispatch])
+ *
+ * useRefreshOnEvents({
+ * events: ['library', 'user'],
+ * onRefresh: loadUserLibraries
+ * })
+ *
+ * @example
+ * // Example 2: Statistics Dashboard - Reload stats when any music data changes
+ * const loadStats = useCallback(async () => {
+ * const stats = await dataProvider.customRequest('GET', 'stats')
+ * setDashboardStats(stats)
+ * }, [dataProvider, setDashboardStats])
+ *
+ * useRefreshOnEvents({
+ * events: ['album', 'song', 'artist'],
+ * onRefresh: loadStats
+ * })
+ *
+ * @example
+ * // Example 3: Permission-based UI - Reload permissions when user changes
+ * const loadPermissions = useCallback(async () => {
+ * const authData = await authProvider.getPermissions()
+ * setUserPermissions(authData)
+ * }, [authProvider, setUserPermissions])
+ *
+ * useRefreshOnEvents({
+ * events: ['user'],
+ * onRefresh: loadPermissions
+ * })
+ *
+ * @example
+ * // Example 4: Listen to all events (use sparingly)
+ * const reloadAll = useCallback(async () => {
+ * // This will trigger on ANY refresh event
+ * await reloadEverything()
+ * }, [reloadEverything])
+ *
+ * useRefreshOnEvents({
+ * events: ['*'],
+ * onRefresh: reloadAll
+ * })
+ */
+export const useRefreshOnEvents = ({ events, onRefresh }) => {
+ const [lastRefreshTime, setLastRefreshTime] = useState(Date.now())
+
+ const refreshData = useSelector(
+ (state) => state.activity?.refresh || { lastReceived: lastRefreshTime },
+ )
+
+ useEffect(() => {
+ const { resources, lastReceived } = refreshData
+
+ // Only process if we have new events
+ if (lastReceived <= lastRefreshTime) {
+ return
+ }
+
+ // Check if any of the events we're interested in occurred
+ const shouldRefresh =
+ resources &&
+ // Global refresh event
+ (resources['*'] === '*' ||
+ // Check for specific events we're listening to
+ events.some((eventType) => {
+ if (eventType === '*') {
+ return true // Listen to all events
+ }
+ return resources[eventType] // Check if this specific event occurred
+ }))
+
+ if (shouldRefresh) {
+ setLastRefreshTime(lastReceived)
+
+ // Call the custom refresh function
+ if (onRefresh) {
+ onRefresh().catch((error) => {
+ // eslint-disable-next-line no-console
+ console.warn('Error in useRefreshOnEvents onRefresh callback:', error)
+ })
+ }
+ }
+ }, [refreshData, lastRefreshTime, events, onRefresh])
+}
diff --git a/ui/src/common/useRefreshOnEvents.test.js b/ui/src/common/useRefreshOnEvents.test.js
new file mode 100644
index 000000000..2306cd3c9
--- /dev/null
+++ b/ui/src/common/useRefreshOnEvents.test.js
@@ -0,0 +1,233 @@
+import { vi } from 'vitest'
+import * as React from 'react'
+import * as Redux from 'react-redux'
+import { useRefreshOnEvents } from './useRefreshOnEvents'
+
+vi.mock('react', async () => {
+ const actual = await vi.importActual('react')
+ return {
+ ...actual,
+ useState: vi.fn(),
+ useEffect: vi.fn(),
+ }
+})
+
+vi.mock('react-redux', async () => {
+ const actual = await vi.importActual('react-redux')
+ return {
+ ...actual,
+ useSelector: vi.fn(),
+ }
+})
+
+describe('useRefreshOnEvents', () => {
+ const setState = vi.fn()
+ const useStateMock = (initState) => [initState, setState]
+ const onRefresh = vi.fn().mockResolvedValue()
+ let lastTime
+ let mockUseEffect
+
+ beforeEach(() => {
+ vi.spyOn(React, 'useState').mockImplementation(useStateMock)
+ mockUseEffect = vi.spyOn(React, 'useEffect')
+ lastTime = new Date(new Date().valueOf() + 1000)
+ onRefresh.mockClear()
+ setState.mockClear()
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('stores last time checked, to avoid redundant runs', () => {
+ const useSelectorMock = () => ({
+ lastReceived: lastTime,
+ resources: { library: ['lib-1'] }, // Need some resources to trigger the update
+ })
+ vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
+
+ // Mock useEffect to immediately call the effect callback
+ mockUseEffect.mockImplementation((callback) => callback())
+
+ useRefreshOnEvents({
+ events: ['library'],
+ onRefresh,
+ })
+
+ expect(setState).toHaveBeenCalledWith(lastTime)
+ })
+
+ it("does not run again if lastTime didn't change", () => {
+ vi.spyOn(React, 'useState').mockImplementation(() => [lastTime, setState])
+ const useSelectorMock = () => ({ lastReceived: lastTime })
+ vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
+
+ // Mock useEffect to immediately call the effect callback
+ mockUseEffect.mockImplementation((callback) => callback())
+
+ useRefreshOnEvents({
+ events: ['library'],
+ onRefresh,
+ })
+
+ expect(setState).not.toHaveBeenCalled()
+ expect(onRefresh).not.toHaveBeenCalled()
+ })
+
+ describe('Event listening and refresh triggering', () => {
+ beforeEach(() => {
+ // Mock useEffect to immediately call the effect callback
+ mockUseEffect.mockImplementation((callback) => callback())
+ })
+
+ it('triggers refresh when a watched event occurs', () => {
+ const useSelectorMock = () => ({
+ lastReceived: lastTime,
+ resources: { library: ['lib-1', 'lib-2'] },
+ })
+ vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
+
+ useRefreshOnEvents({
+ events: ['library'],
+ onRefresh,
+ })
+
+ expect(onRefresh).toHaveBeenCalledTimes(1)
+ expect(setState).toHaveBeenCalledWith(lastTime)
+ })
+
+ it('triggers refresh when multiple watched events occur', () => {
+ const useSelectorMock = () => ({
+ lastReceived: lastTime,
+ resources: {
+ library: ['lib-1'],
+ user: ['user-1'],
+ album: ['album-1'], // This shouldn't trigger since it's not watched
+ },
+ })
+ vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
+
+ useRefreshOnEvents({
+ events: ['library', 'user'],
+ onRefresh,
+ })
+
+ expect(onRefresh).toHaveBeenCalledTimes(1)
+ expect(setState).toHaveBeenCalledWith(lastTime)
+ })
+
+ it('does not trigger refresh when unwatched events occur', () => {
+ const useSelectorMock = () => ({
+ lastReceived: lastTime,
+ resources: { album: ['album-1'], song: ['song-1'] },
+ })
+ vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
+
+ useRefreshOnEvents({
+ events: ['library', 'user'],
+ onRefresh,
+ })
+
+ expect(onRefresh).not.toHaveBeenCalled()
+ expect(setState).not.toHaveBeenCalled()
+ })
+
+ it('triggers refresh on global refresh event', () => {
+ const useSelectorMock = () => ({
+ lastReceived: lastTime,
+ resources: { '*': '*' },
+ })
+ vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
+
+ useRefreshOnEvents({
+ events: ['library'],
+ onRefresh,
+ })
+
+ expect(onRefresh).toHaveBeenCalledTimes(1)
+ expect(setState).toHaveBeenCalledWith(lastTime)
+ })
+
+ it('triggers refresh when listening to all events with "*"', () => {
+ const useSelectorMock = () => ({
+ lastReceived: lastTime,
+ resources: { song: ['song-1'] },
+ })
+ vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
+
+ useRefreshOnEvents({
+ events: ['*'],
+ onRefresh,
+ })
+
+ expect(onRefresh).toHaveBeenCalledTimes(1)
+ expect(setState).toHaveBeenCalledWith(lastTime)
+ })
+
+ it('handles empty events array gracefully', () => {
+ const useSelectorMock = () => ({
+ lastReceived: lastTime,
+ resources: { library: ['lib-1'] },
+ })
+ vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
+
+ useRefreshOnEvents({
+ events: [],
+ onRefresh,
+ })
+
+ expect(onRefresh).not.toHaveBeenCalled()
+ expect(setState).not.toHaveBeenCalled()
+ })
+
+ it('handles missing onRefresh function gracefully', () => {
+ const useSelectorMock = () => ({
+ lastReceived: lastTime,
+ resources: { library: ['lib-1'] },
+ })
+ vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
+
+ expect(() => {
+ useRefreshOnEvents({
+ events: ['library'],
+ // onRefresh is undefined
+ })
+ }).not.toThrow()
+
+ expect(setState).toHaveBeenCalledWith(lastTime)
+ })
+
+ it('handles onRefresh errors gracefully', async () => {
+ const consoleWarnSpy = vi
+ .spyOn(console, 'warn')
+ .mockImplementation(() => {})
+ const failingRefresh = vi
+ .fn()
+ .mockRejectedValue(new Error('Refresh failed'))
+
+ const useSelectorMock = () => ({
+ lastReceived: lastTime,
+ resources: { library: ['lib-1'] },
+ })
+ vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock)
+
+ useRefreshOnEvents({
+ events: ['library'],
+ onRefresh: failingRefresh,
+ })
+
+ expect(failingRefresh).toHaveBeenCalledTimes(1)
+ expect(setState).toHaveBeenCalledWith(lastTime)
+
+ // Wait for the promise to be rejected and handled
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ 'Error in useRefreshOnEvents onRefresh callback:',
+ expect.any(Error),
+ )
+
+ consoleWarnSpy.mockRestore()
+ })
+ })
+})
diff --git a/ui/src/common/useResourceRefresh.jsx b/ui/src/common/useResourceRefresh.jsx
index d9f6aee52..eabff6f92 100644
--- a/ui/src/common/useResourceRefresh.jsx
+++ b/ui/src/common/useResourceRefresh.jsx
@@ -2,6 +2,67 @@ import { useSelector } from 'react-redux'
import { useState } from 'react'
import { useRefresh, useDataProvider } from 'react-admin'
+/**
+ * A hook that automatically refreshes react-admin managed resources when refresh events are received via SSE.
+ *
+ * This hook is designed for components that display react-admin managed resources (like lists, shows, edits)
+ * and need to stay in sync when those resources are modified elsewhere in the application.
+ *
+ * **When to use this hook:**
+ * - Your component displays react-admin resources (albums, songs, artists, playlists, etc.)
+ * - You want automatic refresh when those resources are created/updated/deleted
+ * - Your data comes from standard dataProvider.getMany() calls
+ * - You're using react-admin's data management (queries, mutations, caching)
+ *
+ * **When NOT to use this hook:**
+ * - Your component displays derived/custom data not directly managed by react-admin
+ * - You need custom reload logic beyond dataProvider.getMany()
+ * - Your data comes from non-standard endpoints
+ * - Use `useRefreshOnEvents` instead for these scenarios
+ *
+ * @param {...string} visibleResources - Resource names to watch for changes.
+ * If no resources specified, watches all resources.
+ * If '*' is included in resources, triggers full page refresh.
+ *
+ * @example
+ * // Example 1: Album list - refresh when albums change
+ * const AlbumList = () => {
+ * useResourceRefresh('album')
+ * return ...
+ * }
+ *
+ * @example
+ * // Example 2: Album show page - refresh when album or its songs change
+ * const AlbumShow = () => {
+ * useResourceRefresh('album', 'song')
+ * return ...
+ * }
+ *
+ * @example
+ * // Example 3: Dashboard - refresh when any resource changes
+ * const Dashboard = () => {
+ * useResourceRefresh() // No parameters = watch all resources
+ * return ...
+ * }
+ *
+ * @example
+ * // Example 4: Library management page - watch library resources
+ * const LibraryList = () => {
+ * useResourceRefresh('library')
+ * return ...
+ * }
+ *
+ * **How it works:**
+ * - Listens to refresh events from the SSE connection
+ * - When events arrive, checks if they match the specified visible resources
+ * - For specific resource IDs: calls dataProvider.getMany(resource, {ids: [...]})
+ * - For global refreshes: calls refresh() to reload the entire page
+ * - Uses react-admin's built-in data management and caching
+ *
+ * **Event format expected:**
+ * - Global refresh: { '*': '*' } or { someResource: ['*'] }
+ * - Specific resources: { album: ['id1', 'id2'], song: ['id3'] }
+ */
export const useResourceRefresh = (...visibleResources) => {
const [lastTime, setLastTime] = useState(Date.now())
const refresh = useRefresh()
diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js
index 257a274e8..8b4a0cb62 100644
--- a/ui/src/dataProvider/wrapperDataProvider.js
+++ b/ui/src/dataProvider/wrapperDataProvider.js
@@ -9,25 +9,59 @@ const isAdmin = () => {
return role === 'admin'
}
+const getSelectedLibraries = () => {
+ try {
+ const state = JSON.parse(localStorage.getItem('state'))
+ return state?.library?.selectedLibraries || []
+ } catch (err) {
+ return []
+ }
+}
+
+// Function to apply library filtering to appropriate resources
+const applyLibraryFilter = (resource, params) => {
+ // Content resources that should be filtered by selected libraries
+ const filteredResources = ['album', 'song', 'artist', 'playlistTrack', 'tag']
+
+ // Get selected libraries from localStorage
+ const selectedLibraries = getSelectedLibraries()
+
+ // Add library filter for content resources if libraries are selected
+ if (filteredResources.includes(resource) && selectedLibraries.length > 0) {
+ if (!params.filter) {
+ params.filter = {}
+ }
+ params.filter.library_id = selectedLibraries
+ }
+
+ return params
+}
+
const mapResource = (resource, params) => {
switch (resource) {
+ // /api/playlistTrack?playlist_id=123 => /api/playlist/123/tracks
case 'playlistTrack': {
- // /api/playlistTrack?playlist_id=123 => /api/playlist/123/tracks
+ params.filter = params.filter || {}
+
let plsId = '0'
- if (params.filter) {
- plsId = params.filter.playlist_id
- if (!isAdmin()) {
- params.filter.missing = false
- }
+ plsId = params.filter.playlist_id
+ if (!isAdmin()) {
+ params.filter.missing = false
}
+ params = applyLibraryFilter(resource, params)
+
return [`playlist/${plsId}/tracks`, params]
}
case 'album':
case 'song':
- case 'artist': {
- if (params.filter && !isAdmin()) {
+ case 'artist':
+ case 'tag': {
+ params.filter = params.filter || {}
+ if (!isAdmin()) {
params.filter.missing = false
}
+ params = applyLibraryFilter(resource, params)
+
return [resource, params]
}
default:
@@ -43,6 +77,60 @@ const callDeleteMany = (resource, params) => {
}).then((response) => ({ data: response.json.ids || [] }))
}
+// Helper function to handle user-library associations
+const handleUserLibraryAssociation = async (userId, libraryIds) => {
+ if (!libraryIds || libraryIds.length === 0) {
+ return // Admin users or users without library assignments
+ }
+
+ try {
+ await httpClient(`${REST_URL}/user/${userId}/library`, {
+ method: 'PUT',
+ body: JSON.stringify({ libraryIds }),
+ })
+ } catch (error) {
+ console.error('Error setting user libraries:', error) //eslint-disable-line no-console
+ throw error
+ }
+}
+
+// Enhanced user creation that handles library associations
+const createUser = async (params) => {
+ const { data } = params
+ const { libraryIds, ...userData } = data
+
+ // First create the user
+ const userResponse = await dataProvider.create('user', { data: userData })
+ const userId = userResponse.data.id
+
+ // Then set library associations for non-admin users
+ if (!userData.isAdmin && libraryIds && libraryIds.length > 0) {
+ await handleUserLibraryAssociation(userId, libraryIds)
+ }
+
+ return userResponse
+}
+
+// Enhanced user update that handles library associations
+const updateUser = async (params) => {
+ const { data } = params
+ const { libraryIds, ...userData } = data
+ const userId = params.id
+
+ // First update the user
+ const userResponse = await dataProvider.update('user', {
+ ...params,
+ data: userData,
+ })
+
+ // Then handle library associations for non-admin users
+ if (!userData.isAdmin && libraryIds !== undefined) {
+ await handleUserLibraryAssociation(userId, libraryIds)
+ }
+
+ return userResponse
+}
+
const wrapperDataProvider = {
...dataProvider,
getList: (resource, params) => {
@@ -51,7 +139,19 @@ const wrapperDataProvider = {
},
getOne: (resource, params) => {
const [r, p] = mapResource(resource, params)
- return dataProvider.getOne(r, p)
+ const response = dataProvider.getOne(r, p)
+
+ // Transform user data to ensure libraryIds is present for form compatibility
+ if (resource === 'user') {
+ return response.then((result) => {
+ if (result.data.libraries && Array.isArray(result.data.libraries)) {
+ result.data.libraryIds = result.data.libraries.map((lib) => lib.id)
+ }
+ return result
+ })
+ }
+
+ return response
},
getMany: (resource, params) => {
const [r, p] = mapResource(resource, params)
@@ -62,6 +162,9 @@ const wrapperDataProvider = {
return dataProvider.getManyReference(r, p)
},
update: (resource, params) => {
+ if (resource === 'user') {
+ return updateUser(params)
+ }
const [r, p] = mapResource(resource, params)
return dataProvider.update(r, p)
},
@@ -70,6 +173,9 @@ const wrapperDataProvider = {
return dataProvider.updateMany(r, p)
},
create: (resource, params) => {
+ if (resource === 'user') {
+ return createUser(params)
+ }
const [r, p] = mapResource(resource, params)
return dataProvider.create(r, p)
},
diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json
index 6b647d213..f384df2d2 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -12,6 +12,7 @@
"artist": "Artist",
"album": "Album",
"path": "File path",
+ "libraryName": "Library",
"genre": "Genre",
"compilation": "Compilation",
"year": "Year",
@@ -58,6 +59,7 @@
"playCount": "Plays",
"size": "Size",
"name": "Name",
+ "libraryName": "Library",
"genre": "Genre",
"compilation": "Compilation",
"year": "Year",
@@ -147,19 +149,26 @@
"changePassword": "Change Password?",
"currentPassword": "Current Password",
"newPassword": "New Password",
- "token": "Token"
+ "token": "Token",
+ "libraries": "Libraries"
},
"helperTexts": {
- "name": "Changes to your name will only be reflected on next login"
+ "name": "Changes to your name will only be reflected on next login",
+ "libraries": "Select specific libraries for this user, or leave empty to use default libraries"
},
"notifications": {
"created": "User created",
"updated": "User updated",
"deleted": "User deleted"
},
+ "validation": {
+ "librariesRequired": "At least one library must be selected for non-admin users"
+ },
"message": {
"listenBrainzToken": "Enter your ListenBrainz user token.",
- "clickHereForToken": "Click here to get your token"
+ "clickHereForToken": "Click here to get your token",
+ "selectAllLibraries": "Select all libraries",
+ "adminAutoLibraries": "Admin users automatically have access to all libraries"
}
},
"player": {
@@ -254,6 +263,7 @@
"fields": {
"path": "Path",
"size": "Size",
+ "libraryName": "Library",
"updatedAt": "Disappeared on"
},
"actions": {
@@ -263,6 +273,63 @@
"notifications": {
"removed": "Missing file(s) removed"
}
+ },
+ "library": {
+ "name": "Library |||| Libraries",
+ "fields": {
+ "name": "Name",
+ "path": "Path",
+ "remotePath": "Remote Path",
+ "lastScanAt": "Last Scan",
+ "songCount": "Songs",
+ "albumCount": "Albums",
+ "artistCount": "Artists",
+ "scanCount": "Scan Count",
+ "missingFileCount": "Missing Files",
+ "size": "Size",
+ "duration": "Duration",
+ "totalSongs": "Songs",
+ "totalAlbums": "Albums",
+ "totalArtists": "Artists",
+ "totalFolders": "Folders",
+ "totalFiles": "Files",
+ "totalMissingFiles": "Missing Files",
+ "totalSize": "Total Size",
+ "totalDuration": "Duration",
+ "defaultNewUsers": "Default for New Users",
+ "createdAt": "Created",
+ "updatedAt": "Updated"
+ },
+ "sections": {
+ "basic": "Basic Information",
+ "statistics": "Statistics",
+ "scan": "Scan Information"
+ },
+ "actions": {
+ "scan": "Scan Library",
+ "manageUsers": "Manage User Access",
+ "viewDetails": "View Details"
+ },
+ "notifications": {
+ "created": "Library created successfully",
+ "updated": "Library updated successfully",
+ "deleted": "Library deleted successfully",
+ "scanStarted": "Library scan started",
+ "scanCompleted": "Library scan completed"
+ },
+ "validation": {
+ "nameRequired": "Library name is required",
+ "pathRequired": "Library path is required",
+ "pathNotDirectory": "Library path must be a directory",
+ "pathNotFound": "Library path not found",
+ "pathNotAccessible": "Library path is not accessible",
+ "pathInvalid": "Invalid library path"
+ },
+ "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"
+ }
}
},
"ra": {
@@ -450,6 +517,12 @@
},
"menu": {
"library": "Library",
+ "librarySelector": {
+ "allLibraries": "All Libraries (%{count})",
+ "multipleLibraries": "%{selected} of %{total} Libraries",
+ "selectLibraries": "Select Libraries",
+ "none": "None"
+ },
"settings": "Settings",
"version": "Version",
"theme": "Theme",
diff --git a/ui/src/layout/Menu.jsx b/ui/src/layout/Menu.jsx
index bd1e37ee0..45f40b26d 100644
--- a/ui/src/layout/Menu.jsx
+++ b/ui/src/layout/Menu.jsx
@@ -9,6 +9,7 @@ import SubMenu from './SubMenu'
import { humanize, pluralize } from 'inflection'
import albumLists from '../album/albumLists'
import PlaylistsSubMenu from './PlaylistsSubMenu'
+import LibrarySelector from '../common/LibrarySelector'
import config from '../config'
const useStyles = makeStyles((theme) => ({
@@ -111,6 +112,7 @@ const Menu = ({ dense = false }) => {
[classes.closed]: !open,
})}
>
+ {open && }
handleToggle('menuAlbumList')}
isOpen={state.menuAlbumList}
diff --git a/ui/src/library/DeleteLibraryButton.jsx b/ui/src/library/DeleteLibraryButton.jsx
new file mode 100644
index 000000000..8d9ff6ed2
--- /dev/null
+++ b/ui/src/library/DeleteLibraryButton.jsx
@@ -0,0 +1,80 @@
+import React from 'react'
+import DeleteIcon from '@material-ui/icons/Delete'
+import { makeStyles, alpha } from '@material-ui/core/styles'
+import clsx from 'clsx'
+import {
+ useNotify,
+ useDeleteWithConfirmController,
+ Button,
+ Confirm,
+ useTranslate,
+ useRedirect,
+} from 'react-admin'
+
+const useStyles = makeStyles(
+ (theme) => ({
+ deleteButton: {
+ color: theme.palette.error.main,
+ '&:hover': {
+ backgroundColor: alpha(theme.palette.error.main, 0.12),
+ // Reset on mouse devices
+ '@media (hover: none)': {
+ backgroundColor: 'transparent',
+ },
+ },
+ },
+ }),
+ { name: 'RaDeleteWithConfirmButton' },
+)
+
+const DeleteLibraryButton = ({
+ record,
+ resource,
+ basePath,
+ className,
+ ...props
+}) => {
+ const translate = useTranslate()
+ const notify = useNotify()
+ const redirect = useRedirect()
+
+ const onSuccess = () => {
+ notify('resources.library.notifications.deleted', 'info', {
+ smart_count: 1,
+ })
+ redirect('/library')
+ }
+
+ const { open, loading, handleDialogOpen, handleDialogClose, handleDelete } =
+ useDeleteWithConfirmController({
+ resource,
+ record,
+ basePath,
+ onSuccess,
+ })
+
+ const classes = useStyles(props)
+ return (
+ <>
+
+
+ >
+ )
+}
+
+export default DeleteLibraryButton
diff --git a/ui/src/library/LibraryCreate.jsx b/ui/src/library/LibraryCreate.jsx
new file mode 100644
index 000000000..0e69964b6
--- /dev/null
+++ b/ui/src/library/LibraryCreate.jsx
@@ -0,0 +1,84 @@
+import React, { useCallback } from 'react'
+import {
+ Create,
+ SimpleForm,
+ TextInput,
+ BooleanInput,
+ required,
+ useTranslate,
+ useMutation,
+ useNotify,
+ useRedirect,
+} from 'react-admin'
+import { Title } from '../common'
+
+const LibraryCreate = (props) => {
+ const translate = useTranslate()
+ const [mutate] = useMutation()
+ const notify = useNotify()
+ const redirect = useRedirect()
+ const resourceName = translate('resources.library.name', { smart_count: 1 })
+ const title = translate('ra.page.create', {
+ name: `${resourceName}`,
+ })
+
+ const save = useCallback(
+ async (values) => {
+ try {
+ await mutate(
+ {
+ type: 'create',
+ resource: 'library',
+ payload: { data: values },
+ },
+ { returnPromise: true },
+ )
+ notify('resources.library.notifications.created', 'info', {
+ smart_count: 1,
+ })
+ redirect('/library')
+ } catch (error) {
+ // Handle validation errors with proper field mapping
+ if (error.body && error.body.errors) {
+ return error.body.errors
+ }
+
+ // Handle other structured errors from the server
+ if (error.body && error.body.error) {
+ const errorMsg = error.body.error
+
+ // Handle database constraint violations
+ if (errorMsg.includes('UNIQUE constraint failed: library.name')) {
+ return { name: 'ra.validation.unique' }
+ }
+ if (errorMsg.includes('UNIQUE constraint failed: library.path')) {
+ return { path: 'ra.validation.unique' }
+ }
+
+ // Show a general notification for other server errors
+ notify(errorMsg, 'error')
+ return
+ }
+
+ // Fallback for unexpected error formats
+ const fallbackMessage =
+ error.message ||
+ (typeof error === 'string' ? error : 'An unexpected error occurred')
+ notify(fallbackMessage, 'error')
+ }
+ },
+ [mutate, notify, redirect],
+ )
+
+ return (
+ } {...props}>
+
+
+
+
+
+
+ )
+}
+
+export default LibraryCreate
diff --git a/ui/src/library/LibraryEdit.jsx b/ui/src/library/LibraryEdit.jsx
new file mode 100644
index 000000000..f00fbf7c6
--- /dev/null
+++ b/ui/src/library/LibraryEdit.jsx
@@ -0,0 +1,274 @@
+import React, { useCallback } from 'react'
+import {
+ Edit,
+ FormWithRedirect,
+ TextInput,
+ BooleanInput,
+ required,
+ SaveButton,
+ DateField,
+ useTranslate,
+ useMutation,
+ useNotify,
+ useRedirect,
+ NumberInput,
+ Toolbar,
+} from 'react-admin'
+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'
+
+const useStyles = makeStyles({
+ toolbar: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ },
+})
+
+const LibraryTitle = ({ record }) => {
+ const translate = useTranslate()
+ const resourceName = translate('resources.library.name', { smart_count: 1 })
+ return (
+
+ )
+}
+
+const CustomToolbar = ({ showDelete, ...props }) => (
+
+
+ {showDelete && (
+
+ )}
+
+)
+
+const LibraryEdit = (props) => {
+ const translate = useTranslate()
+ const [mutate] = useMutation()
+ const notify = useNotify()
+ const redirect = useRedirect()
+
+ // Library ID 1 is protected (main library)
+ const canDelete = props.id !== '1'
+ const canEditPath = props.id !== '1'
+
+ const save = useCallback(
+ async (values) => {
+ try {
+ await mutate(
+ {
+ type: 'update',
+ resource: 'library',
+ payload: { id: values.id, data: values },
+ },
+ { returnPromise: true },
+ )
+ notify('resources.library.notifications.updated', 'info', {
+ smart_count: 1,
+ })
+ redirect('/library')
+ } catch (error) {
+ if (error.body && error.body.errors) {
+ return error.body.errors
+ }
+ }
+ },
+ [mutate, notify, redirect],
+ )
+
+ return (
+ } undoable={false} {...props}>
+ (
+
+ )}
+ />
+
+ )
+}
+
+export default LibraryEdit
diff --git a/ui/src/library/LibraryList.jsx b/ui/src/library/LibraryList.jsx
new file mode 100644
index 000000000..c2d2f6295
--- /dev/null
+++ b/ui/src/library/LibraryList.jsx
@@ -0,0 +1,56 @@
+import React from 'react'
+import {
+ Datagrid,
+ Filter,
+ SearchInput,
+ SimpleList,
+ TextField,
+ NumberField,
+ BooleanField,
+} from 'react-admin'
+import { useMediaQuery } from '@material-ui/core'
+import { List, DateField, useResourceRefresh } from '../common'
+
+const LibraryFilter = (props) => (
+
+
+
+)
+
+const LibraryList = (props) => {
+ const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
+ useResourceRefresh('library')
+
+ return (
+
}
+ >
+ {isXsmall ? (
+ record.name}
+ secondaryText={(record) => record.path}
+ />
+ ) : (
+
+
+
+
+
+
+
+
+
+ )}
+
+ )
+}
+
+export default LibraryList
diff --git a/ui/src/library/index.js b/ui/src/library/index.js
new file mode 100644
index 000000000..3a8b71b52
--- /dev/null
+++ b/ui/src/library/index.js
@@ -0,0 +1,11 @@
+import { MdLibraryMusic } from 'react-icons/md'
+import LibraryList from './LibraryList'
+import LibraryEdit from './LibraryEdit'
+import LibraryCreate from './LibraryCreate'
+
+export default {
+ icon: MdLibraryMusic,
+ list: LibraryList,
+ edit: LibraryEdit,
+ create: LibraryCreate,
+}
diff --git a/ui/src/missing/MissingFilesList.jsx b/ui/src/missing/MissingFilesList.jsx
index 74711eed0..87d9f629f 100644
--- a/ui/src/missing/MissingFilesList.jsx
+++ b/ui/src/missing/MissingFilesList.jsx
@@ -5,10 +5,15 @@ import {
TextField,
downloadCSV,
Pagination,
+ Filter,
+ ReferenceInput,
+ useTranslate,
+ SelectInput,
} from 'react-admin'
import jsonExport from 'jsonexport/dist'
import DeleteMissingFilesButton from './DeleteMissingFilesButton.jsx'
import MissingListActions from './MissingListActions.jsx'
+import React from 'react'
const exporter = (files) => {
const filesToExport = files.map((file) => {
@@ -20,6 +25,24 @@ const exporter = (files) => {
})
}
+const MissingFilesFilter = (props) => {
+ const translate = useTranslate()
+ return (
+
+ ({ name: [searchText] })}
+ alwaysOn
+ >
+
+
+
+ )
+}
+
const BulkActionButtons = (props) => (
<>
@@ -38,11 +61,13 @@ const MissingFilesList = (props) => {
sort={{ field: 'updated_at', order: 'DESC' }}
exporter={exporter}
actions={}
+ filters={}
bulkActionButtons={}
perPage={50}
pagination={}
>
+
diff --git a/ui/src/reducers/index.js b/ui/src/reducers/index.js
index b9414c864..3db0b1dff 100644
--- a/ui/src/reducers/index.js
+++ b/ui/src/reducers/index.js
@@ -1,3 +1,4 @@
+export * from './libraryReducer'
export * from './themeReducer'
export * from './dialogReducer'
export * from './playerReducer'
diff --git a/ui/src/reducers/libraryReducer.js b/ui/src/reducers/libraryReducer.js
new file mode 100644
index 000000000..7cda10bcf
--- /dev/null
+++ b/ui/src/reducers/libraryReducer.js
@@ -0,0 +1,31 @@
+import { SET_SELECTED_LIBRARIES, SET_USER_LIBRARIES } from '../actions'
+
+const initialState = {
+ userLibraries: [],
+ selectedLibraries: [], // Empty means "all accessible libraries"
+}
+
+export const libraryReducer = (previousState = initialState, payload) => {
+ const { type, data } = payload
+ switch (type) {
+ case SET_USER_LIBRARIES:
+ return {
+ ...previousState,
+ userLibraries: data,
+ // If this is the first time setting user libraries and no selection exists,
+ // default to all libraries
+ selectedLibraries:
+ previousState.selectedLibraries.length === 0 &&
+ previousState.userLibraries.length === 0
+ ? data.map((lib) => lib.id)
+ : previousState.selectedLibraries,
+ }
+ case SET_SELECTED_LIBRARIES:
+ return {
+ ...previousState,
+ selectedLibraries: data,
+ }
+ default:
+ return previousState
+ }
+}
diff --git a/ui/src/store/createAdminStore.js b/ui/src/store/createAdminStore.js
index e4877eb14..4888e49e4 100644
--- a/ui/src/store/createAdminStore.js
+++ b/ui/src/store/createAdminStore.js
@@ -57,6 +57,7 @@ const createAdminStore = ({
const state = store.getState()
saveState({
theme: state.theme,
+ library: state.library,
player: (({ queue, volume, savedPlayIndex }) => ({
queue,
volume,
diff --git a/ui/src/user/LibrarySelectionField.jsx b/ui/src/user/LibrarySelectionField.jsx
new file mode 100644
index 000000000..4967720cd
--- /dev/null
+++ b/ui/src/user/LibrarySelectionField.jsx
@@ -0,0 +1,55 @@
+import { useInput, useTranslate, useRecordContext } from 'react-admin'
+import { Box, FormControl, FormLabel, Typography } from '@material-ui/core'
+import { SelectLibraryInput } from '../common/SelectLibraryInput.jsx'
+import React, { useMemo } from 'react'
+
+export const LibrarySelectionField = () => {
+ const translate = useTranslate()
+ const record = useRecordContext()
+
+ const {
+ input: { name, onChange, value },
+ meta: { error, touched },
+ } = useInput({ source: 'libraryIds' })
+
+ // Extract library IDs from either 'libraries' array or 'libraryIds' array
+ const libraryIds = useMemo(() => {
+ // First check if form has libraryIds (create mode or already transformed)
+ if (value && Array.isArray(value)) {
+ return value
+ }
+
+ // Then check if record has libraries array (edit mode from backend)
+ if (record?.libraries && Array.isArray(record.libraries)) {
+ return record.libraries.map((lib) => lib.id)
+ }
+
+ return []
+ }, [value, record])
+
+ // Determine if this is a new user (no ID means new record)
+ const isNewUser = !record?.id
+
+ return (
+
+
+ {translate('resources.user.fields.libraries')}
+
+
+
+
+ {touched && error && (
+
+ {error}
+
+ )}
+
+ {translate('resources.user.helperTexts.libraries')}
+
+
+ )
+}
diff --git a/ui/src/user/LibrarySelectionField.test.jsx b/ui/src/user/LibrarySelectionField.test.jsx
new file mode 100644
index 000000000..9777bab99
--- /dev/null
+++ b/ui/src/user/LibrarySelectionField.test.jsx
@@ -0,0 +1,168 @@
+import * as React from 'react'
+import { render, screen, cleanup } from '@testing-library/react'
+import { LibrarySelectionField } from './LibrarySelectionField'
+import { useInput, useTranslate, useRecordContext } from 'react-admin'
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { SelectLibraryInput } from '../common/SelectLibraryInput'
+
+// Mock the react-admin hooks
+vi.mock('react-admin', () => ({
+ useInput: vi.fn(),
+ useTranslate: vi.fn(),
+ useRecordContext: vi.fn(),
+}))
+
+// Mock the SelectLibraryInput component
+vi.mock('../common/SelectLibraryInput.jsx', () => ({
+ SelectLibraryInput: vi.fn(() => ),
+}))
+
+describe('', () => {
+ const defaultProps = {
+ input: {
+ name: 'libraryIds',
+ value: [],
+ onChange: vi.fn(),
+ },
+ meta: {
+ touched: false,
+ error: undefined,
+ },
+ }
+
+ const mockTranslate = vi.fn((key) => key)
+
+ beforeEach(() => {
+ useInput.mockReturnValue(defaultProps)
+ useTranslate.mockReturnValue(mockTranslate)
+ useRecordContext.mockReturnValue({})
+ SelectLibraryInput.mockClear()
+ })
+
+ afterEach(cleanup)
+
+ it('should render field label from translations', () => {
+ render()
+ expect(screen.getByText('resources.user.fields.libraries')).not.toBeNull()
+ })
+
+ it('should render helper text from translations', () => {
+ render()
+ expect(
+ screen.getByText('resources.user.helperTexts.libraries'),
+ ).not.toBeNull()
+ })
+
+ it('should render SelectLibraryInput with correct props', () => {
+ render()
+ expect(screen.getByTestId('select-library-input')).not.toBeNull()
+ expect(SelectLibraryInput).toHaveBeenCalledWith(
+ expect.objectContaining({
+ onChange: defaultProps.input.onChange,
+ value: defaultProps.input.value,
+ }),
+ expect.anything(),
+ )
+ })
+
+ it('should render error message when touched and has error', () => {
+ useInput.mockReturnValue({
+ ...defaultProps,
+ meta: {
+ touched: true,
+ error: 'This field is required',
+ },
+ })
+
+ render()
+ expect(screen.getByText('This field is required')).not.toBeNull()
+ })
+
+ it('should not render error message when not touched', () => {
+ useInput.mockReturnValue({
+ ...defaultProps,
+ meta: {
+ touched: false,
+ error: 'This field is required',
+ },
+ })
+
+ render()
+ expect(screen.queryByText('This field is required')).toBeNull()
+ })
+
+ it('should initialize with empty array when value is null', () => {
+ useInput.mockReturnValue({
+ ...defaultProps,
+ input: {
+ ...defaultProps.input,
+ value: null,
+ },
+ })
+
+ render()
+ expect(SelectLibraryInput).toHaveBeenCalledWith(
+ expect.objectContaining({
+ value: [],
+ }),
+ expect.anything(),
+ )
+ })
+
+ it('should extract library IDs from record libraries array when editing user', () => {
+ // Mock a record with libraries array (from backend during edit)
+ useRecordContext.mockReturnValue({
+ id: 'user123',
+ name: 'John Doe',
+ libraries: [
+ { id: 1, name: 'Music Library 1', path: '/music1' },
+ { id: 3, name: 'Music Library 3', path: '/music3' },
+ ],
+ })
+
+ // Mock input without libraryIds (edit mode scenario)
+ useInput.mockReturnValue({
+ ...defaultProps,
+ input: {
+ ...defaultProps.input,
+ value: undefined,
+ },
+ })
+
+ render()
+ expect(SelectLibraryInput).toHaveBeenCalledWith(
+ expect.objectContaining({
+ value: [1, 3], // Should extract IDs from libraries array
+ }),
+ expect.anything(),
+ )
+ })
+
+ it('should prefer libraryIds when both libraryIds and libraries are present', () => {
+ // Mock a record with libraries array
+ useRecordContext.mockReturnValue({
+ id: 'user123',
+ libraries: [
+ { id: 1, name: 'Music Library 1', path: '/music1' },
+ { id: 3, name: 'Music Library 3', path: '/music3' },
+ ],
+ })
+
+ // Mock input with explicit libraryIds (create mode or already transformed)
+ useInput.mockReturnValue({
+ ...defaultProps,
+ input: {
+ ...defaultProps.input,
+ value: [2, 4], // Different IDs than in libraries
+ },
+ })
+
+ render()
+ expect(SelectLibraryInput).toHaveBeenCalledWith(
+ expect.objectContaining({
+ value: [2, 4], // Should prefer libraryIds over libraries
+ }),
+ expect.anything(),
+ )
+ })
+})
diff --git a/ui/src/user/UserCreate.jsx b/ui/src/user/UserCreate.jsx
index 42ea1ce94..ce69b6542 100644
--- a/ui/src/user/UserCreate.jsx
+++ b/ui/src/user/UserCreate.jsx
@@ -2,17 +2,20 @@ import React, { useCallback } from 'react'
import {
BooleanInput,
Create,
- TextInput,
+ email,
+ FormDataConsumer,
PasswordInput,
required,
- email,
SimpleForm,
- useTranslate,
+ TextInput,
useMutation,
useNotify,
useRedirect,
+ useTranslate,
} from 'react-admin'
+import { Typography } from '@material-ui/core'
import { Title } from '../common'
+import { LibrarySelectionField } from './LibrarySelectionField.jsx'
const UserCreate = (props) => {
const translate = useTranslate()
@@ -48,9 +51,17 @@ const UserCreate = (props) => {
[mutate, notify, redirect],
)
+ // Custom validation function
+ const validateUserForm = (values) => {
+ const errors = {}
+ // Library selection is optional for non-admin users since they will be auto-assigned to default libraries
+ // No validation required for library selection
+ return errors
+ }
+
return (
} {...props}>
-
+
{
validate={[required()]}
/>
+
+ {/* Conditional Library Selection */}
+
+ {({ formData }) => (
+ <>
+ {!formData.isAdmin && }
+
+ {formData.isAdmin && (
+
+ {translate('resources.user.message.adminAutoLibraries')}
+
+ )}
+ >
+ )}
+
)
diff --git a/ui/src/user/UserEdit.jsx b/ui/src/user/UserEdit.jsx
index 445f9c6fd..2283dd8bc 100644
--- a/ui/src/user/UserEdit.jsx
+++ b/ui/src/user/UserEdit.jsx
@@ -18,9 +18,13 @@ import {
useRefresh,
FormDataConsumer,
usePermissions,
+ useRecordContext,
} from 'react-admin'
+import { Typography } from '@material-ui/core'
import { Title } from '../common'
import DeleteUserButton from './DeleteUserButton'
+import { LibrarySelectionField } from './LibrarySelectionField.jsx'
+import { validateUserForm } from './userValidation'
const useStyles = makeStyles({
toolbar: {
@@ -100,12 +104,18 @@ const UserEdit = (props) => {
[mutate, notify, permissions, redirect, refresh],
)
+ // Custom validation function
+ const validateForm = (values) => {
+ return validateUserForm(values, translate)
+ }
+
return (
} undoable={false} {...props}>
}
save={save}
+ validate={validateForm}
>
{permissions === 'admin' && (
{
{permissions === 'admin' && (
)}
+
+ {/* Conditional Library Selection for Admin Users Only */}
+ {permissions === 'admin' && (
+
+ {({ formData }) => (
+ <>
+ {!formData.isAdmin && }
+
+ {formData.isAdmin && (
+
+ {translate('resources.user.message.adminAutoLibraries')}
+
+ )}
+ >
+ )}
+
+ )}
+
diff --git a/ui/src/user/UserEdit.test.jsx b/ui/src/user/UserEdit.test.jsx
new file mode 100644
index 000000000..75a9a1ada
--- /dev/null
+++ b/ui/src/user/UserEdit.test.jsx
@@ -0,0 +1,130 @@
+import * as React from 'react'
+import { render, screen } from '@testing-library/react'
+import UserEdit from './UserEdit'
+import { describe, it, expect, vi } from 'vitest'
+
+const defaultUser = {
+ id: 'user1',
+ userName: 'testuser',
+ name: 'Test User',
+ email: 'test@example.com',
+ isAdmin: false,
+ libraries: [
+ { id: 1, name: 'Library 1', path: '/music1' },
+ { id: 2, name: 'Library 2', path: '/music2' },
+ ],
+ lastLoginAt: '2023-01-01T12:00:00Z',
+ lastAccessAt: '2023-01-02T12:00:00Z',
+ updatedAt: '2023-01-03T12:00:00Z',
+ createdAt: '2023-01-04T12:00:00Z',
+}
+
+const adminUser = {
+ ...defaultUser,
+ id: 'admin1',
+ userName: 'admin',
+ name: 'Admin User',
+ isAdmin: true,
+}
+
+// Mock React-Admin completely with simpler implementations
+vi.mock('react-admin', () => ({
+ Edit: ({ children, title }) => (
+
+ {title}
+ {children}
+
+ ),
+ SimpleForm: ({ children }) => (
+
+ ),
+ TextInput: ({ source }) => ,
+ BooleanInput: ({ source }) => (
+
+ ),
+ DateField: ({ source }) => (
+ Date
+ ),
+ PasswordInput: ({ source }) => (
+
+ ),
+ Toolbar: ({ children }) => {children}
,
+ SaveButton: () => ,
+ FormDataConsumer: ({ children }) => children({ formData: {} }),
+ Typography: ({ children }) => {children}
,
+ required: () => () => null,
+ email: () => () => null,
+ useMutation: () => [vi.fn()],
+ useNotify: () => vi.fn(),
+ useRedirect: () => vi.fn(),
+ useRefresh: () => vi.fn(),
+ usePermissions: () => ({ permissions: 'admin' }),
+ useTranslate: () => (key) => key,
+}))
+
+vi.mock('./LibrarySelectionField.jsx', () => ({
+ LibrarySelectionField: () => ,
+}))
+
+vi.mock('./DeleteUserButton', () => ({
+ __esModule: true,
+ default: () => ,
+}))
+
+vi.mock('../common', () => ({
+ Title: ({ subTitle }) => {subTitle}
,
+}))
+
+// Mock Material-UI
+vi.mock('@material-ui/core/styles', () => ({
+ makeStyles: () => () => ({}),
+}))
+
+vi.mock('@material-ui/core', () => ({
+ Typography: ({ children }) => {children}
,
+}))
+
+describe('', () => {
+ it('should render the user edit form', () => {
+ render()
+
+ // Check if the edit component renders
+ expect(screen.getByTestId('edit-component')).toBeInTheDocument()
+ expect(screen.getByTestId('simple-form')).toBeInTheDocument()
+ })
+
+ it('should render text inputs for admin users', () => {
+ render()
+
+ // Should render username input for admin
+ expect(screen.getByTestId('text-input-userName')).toBeInTheDocument()
+ expect(screen.getByTestId('text-input-name')).toBeInTheDocument()
+ expect(screen.getByTestId('text-input-email')).toBeInTheDocument()
+ })
+
+ it('should render admin checkbox for admin permissions', () => {
+ render()
+
+ // Should render isAdmin checkbox for admin users
+ expect(screen.getByTestId('boolean-input-isAdmin')).toBeInTheDocument()
+ })
+
+ it('should render date fields', () => {
+ render()
+
+ expect(screen.getByTestId('date-field-lastLoginAt')).toBeInTheDocument()
+ expect(screen.getByTestId('date-field-lastAccessAt')).toBeInTheDocument()
+ expect(screen.getByTestId('date-field-updatedAt')).toBeInTheDocument()
+ expect(screen.getByTestId('date-field-createdAt')).toBeInTheDocument()
+ })
+
+ it('should not render username input for non-admin users', () => {
+ render()
+
+ // Should not render username input for non-admin
+ expect(screen.queryByTestId('text-input-userName')).not.toBeInTheDocument()
+ // But should still render name and email
+ expect(screen.getByTestId('text-input-name')).toBeInTheDocument()
+ expect(screen.getByTestId('text-input-email')).toBeInTheDocument()
+ })
+})
diff --git a/ui/src/user/userValidation.js b/ui/src/user/userValidation.js
new file mode 100644
index 000000000..e90fd2acb
--- /dev/null
+++ b/ui/src/user/userValidation.js
@@ -0,0 +1,19 @@
+// User form validation utilities
+export const validateUserForm = (values, translate) => {
+ const errors = {}
+
+ // Only require library selection for non-admin users
+ if (!values.isAdmin) {
+ // Check both libraryIds (array of IDs) and libraries (array of objects)
+ const hasLibraryIds = values.libraryIds && values.libraryIds.length > 0
+ const hasLibraries = values.libraries && values.libraries.length > 0
+
+ if (!hasLibraryIds && !hasLibraries) {
+ errors.libraryIds = translate(
+ 'resources.user.validation.librariesRequired',
+ )
+ }
+ }
+
+ return errors
+}
diff --git a/ui/src/user/userValidation.test.js b/ui/src/user/userValidation.test.js
new file mode 100644
index 000000000..2ee473910
--- /dev/null
+++ b/ui/src/user/userValidation.test.js
@@ -0,0 +1,70 @@
+import { describe, it, expect, vi } from 'vitest'
+import { validateUserForm } from './userValidation'
+
+describe('User Validation Utilities', () => {
+ const mockTranslate = vi.fn((key) => key)
+
+ describe('validateUserForm', () => {
+ it('should not return errors for admin users', () => {
+ const values = {
+ isAdmin: true,
+ libraryIds: [],
+ }
+ const errors = validateUserForm(values, mockTranslate)
+ expect(errors).toEqual({})
+ })
+
+ it('should not return errors for non-admin users with libraries', () => {
+ const values = {
+ isAdmin: false,
+ libraryIds: [1, 2, 3],
+ }
+ const errors = validateUserForm(values, mockTranslate)
+ expect(errors).toEqual({})
+ })
+
+ it('should return error for non-admin users without libraries', () => {
+ const values = {
+ isAdmin: false,
+ libraryIds: [],
+ }
+ const errors = validateUserForm(values, mockTranslate)
+ expect(errors.libraryIds).toBe(
+ 'resources.user.validation.librariesRequired',
+ )
+ })
+
+ it('should return error for non-admin users with undefined libraryIds', () => {
+ const values = {
+ isAdmin: false,
+ }
+ const errors = validateUserForm(values, mockTranslate)
+ expect(errors.libraryIds).toBe(
+ 'resources.user.validation.librariesRequired',
+ )
+ })
+
+ it('should not return errors for non-admin users with libraries array', () => {
+ const values = {
+ isAdmin: false,
+ libraries: [
+ { id: 1, name: 'Library 1' },
+ { id: 2, name: 'Library 2' },
+ ],
+ }
+ const errors = validateUserForm(values, mockTranslate)
+ expect(errors).toEqual({})
+ })
+
+ it('should return error for non-admin users with empty libraries array', () => {
+ const values = {
+ isAdmin: false,
+ libraries: [],
+ }
+ const errors = validateUserForm(values, mockTranslate)
+ expect(errors.libraryIds).toBe(
+ 'resources.user.validation.librariesRequired',
+ )
+ })
+ })
+})
diff --git a/ui/src/utils/formatters.js b/ui/src/utils/formatters.js
index ae27f230f..74cce6e15 100644
--- a/ui/src/utils/formatters.js
+++ b/ui/src/utils/formatters.js
@@ -25,6 +25,42 @@ export const formatDuration = (d) => {
return `${days > 0 ? days + ':' : ''}${f}`
}
+export const formatDuration2 = (totalSeconds) => {
+ if (totalSeconds == null || totalSeconds < 0) {
+ return '0s'
+ }
+ const days = Math.floor(totalSeconds / 86400)
+ const hours = Math.floor((totalSeconds % 86400) / 3600)
+ const minutes = Math.floor((totalSeconds % 3600) / 60)
+ const seconds = Math.floor(totalSeconds % 60)
+
+ const parts = []
+
+ if (days > 0) {
+ // When days are present, show only d h m (3 levels max)
+ parts.push(`${days}d`)
+ if (hours > 0) {
+ parts.push(`${hours}h`)
+ }
+ if (minutes > 0) {
+ parts.push(`${minutes}m`)
+ }
+ } else {
+ // When no days, show h m s (3 levels max)
+ if (hours > 0) {
+ parts.push(`${hours}h`)
+ }
+ if (minutes > 0) {
+ parts.push(`${minutes}m`)
+ }
+ if (seconds > 0 || parts.length === 0) {
+ parts.push(`${seconds}s`)
+ }
+ }
+
+ return parts.join(' ')
+}
+
export const formatShortDuration = (ns) => {
// Convert nanoseconds to seconds
const seconds = ns / 1e9
@@ -58,3 +94,8 @@ export const formatFullDate = (date, locale) => {
}
return new Date(date).toLocaleDateString(locale, options)
}
+
+export const formatNumber = (value) => {
+ if (value === null || value === undefined) return '0'
+ return value.toLocaleString()
+}
diff --git a/ui/src/utils/formatters.test.js b/ui/src/utils/formatters.test.js
index 87b40f16b..7709dd91b 100644
--- a/ui/src/utils/formatters.test.js
+++ b/ui/src/utils/formatters.test.js
@@ -1,7 +1,9 @@
import {
formatBytes,
formatDuration,
+ formatDuration2,
formatFullDate,
+ formatNumber,
formatShortDuration,
} from './formatters'
@@ -64,6 +66,85 @@ describe('formatShortDuration', () => {
})
})
+describe('formatDuration2', () => {
+ it('handles null and undefined values', () => {
+ expect(formatDuration2(null)).toEqual('0s')
+ expect(formatDuration2(undefined)).toEqual('0s')
+ })
+
+ it('handles negative values', () => {
+ expect(formatDuration2(-10)).toEqual('0s')
+ expect(formatDuration2(-1)).toEqual('0s')
+ })
+
+ it('formats zero seconds', () => {
+ expect(formatDuration2(0)).toEqual('0s')
+ })
+
+ it('formats seconds only', () => {
+ expect(formatDuration2(1)).toEqual('1s')
+ expect(formatDuration2(30)).toEqual('30s')
+ expect(formatDuration2(59)).toEqual('59s')
+ })
+
+ it('formats minutes and seconds', () => {
+ expect(formatDuration2(60)).toEqual('1m')
+ expect(formatDuration2(90)).toEqual('1m 30s')
+ expect(formatDuration2(119)).toEqual('1m 59s')
+ expect(formatDuration2(120)).toEqual('2m')
+ })
+
+ it('formats hours, minutes and seconds', () => {
+ expect(formatDuration2(3600)).toEqual('1h')
+ expect(formatDuration2(3661)).toEqual('1h 1m 1s')
+ expect(formatDuration2(7200)).toEqual('2h')
+ expect(formatDuration2(7260)).toEqual('2h 1m')
+ expect(formatDuration2(7261)).toEqual('2h 1m 1s')
+ })
+
+ it('handles decimal values by flooring', () => {
+ expect(formatDuration2(59.9)).toEqual('59s')
+ expect(formatDuration2(60.1)).toEqual('1m')
+ expect(formatDuration2(3600.9)).toEqual('1h')
+ })
+
+ it('formats days with maximum 3 levels (d h m)', () => {
+ expect(formatDuration2(86400)).toEqual('1d')
+ expect(formatDuration2(86461)).toEqual('1d 1m') // seconds dropped when days present
+ expect(formatDuration2(90061)).toEqual('1d 1h 1m') // seconds dropped when days present
+ expect(formatDuration2(172800)).toEqual('2d')
+ expect(formatDuration2(176400)).toEqual('2d 1h')
+ expect(formatDuration2(176460)).toEqual('2d 1h 1m')
+ expect(formatDuration2(176461)).toEqual('2d 1h 1m') // seconds dropped when days present
+ })
+})
+
+describe('formatNumber', () => {
+ it('handles null and undefined values', () => {
+ expect(formatNumber(null)).toEqual('0')
+ expect(formatNumber(undefined)).toEqual('0')
+ })
+
+ it('formats integers', () => {
+ expect(formatNumber(0)).toEqual('0')
+ expect(formatNumber(1)).toEqual('1')
+ expect(formatNumber(123)).toEqual('123')
+ expect(formatNumber(1000)).toEqual('1,000')
+ expect(formatNumber(1234567)).toEqual('1,234,567')
+ })
+
+ it('formats decimal numbers', () => {
+ expect(formatNumber(123.45)).toEqual('123.45')
+ expect(formatNumber(1234.567)).toEqual('1,234.567')
+ })
+
+ it('formats negative numbers', () => {
+ expect(formatNumber(-123)).toEqual('-123')
+ expect(formatNumber(-1234)).toEqual('-1,234')
+ expect(formatNumber(-123.45)).toEqual('-123.45')
+ })
+})
+
describe('formatFullDate', () => {
it('format dates', () => {
expect(formatFullDate('2011', 'en-US')).toEqual('2011')
diff --git a/utils/files_test.go b/utils/files_test.go
new file mode 100644
index 000000000..dcb28aafb
--- /dev/null
+++ b/utils/files_test.go
@@ -0,0 +1,178 @@
+package utils_test
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/navidrome/navidrome/utils"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("TempFileName", func() {
+ It("creates a temporary file name with prefix and suffix", func() {
+ prefix := "test-"
+ suffix := ".tmp"
+ result := utils.TempFileName(prefix, suffix)
+
+ Expect(result).To(ContainSubstring(prefix))
+ Expect(result).To(HaveSuffix(suffix))
+ Expect(result).To(ContainSubstring(os.TempDir()))
+ })
+
+ It("creates unique file names on multiple calls", func() {
+ prefix := "unique-"
+ suffix := ".test"
+
+ result1 := utils.TempFileName(prefix, suffix)
+ result2 := utils.TempFileName(prefix, suffix)
+
+ Expect(result1).NotTo(Equal(result2))
+ })
+
+ It("handles empty prefix and suffix", func() {
+ result := utils.TempFileName("", "")
+
+ Expect(result).To(ContainSubstring(os.TempDir()))
+ Expect(len(result)).To(BeNumerically(">", len(os.TempDir())))
+ })
+
+ It("creates proper file path separators", func() {
+ prefix := "path-test-"
+ suffix := ".ext"
+ result := utils.TempFileName(prefix, suffix)
+
+ expectedDir := os.TempDir()
+ Expect(result).To(HavePrefix(expectedDir))
+ Expect(strings.Count(result, string(filepath.Separator))).To(BeNumerically(">=", strings.Count(expectedDir, string(filepath.Separator))))
+ })
+})
+
+var _ = Describe("BaseName", func() {
+ It("extracts basename from a simple filename", func() {
+ result := utils.BaseName("test.mp3")
+ Expect(result).To(Equal("test"))
+ })
+
+ It("extracts basename from a file path", func() {
+ result := utils.BaseName("/path/to/file.txt")
+ Expect(result).To(Equal("file"))
+ })
+
+ It("handles files without extension", func() {
+ result := utils.BaseName("/path/to/filename")
+ Expect(result).To(Equal("filename"))
+ })
+
+ It("handles files with multiple dots", func() {
+ result := utils.BaseName("archive.tar.gz")
+ Expect(result).To(Equal("archive.tar"))
+ })
+
+ It("handles hidden files", func() {
+ // For hidden files without additional extension, path.Ext returns the entire name
+ // So basename becomes empty string after TrimSuffix
+ result := utils.BaseName(".hidden")
+ Expect(result).To(Equal(""))
+ })
+
+ It("handles hidden files with extension", func() {
+ result := utils.BaseName(".config.json")
+ Expect(result).To(Equal(".config"))
+ })
+
+ It("handles empty string", func() {
+ // The actual behavior returns empty string for empty input
+ result := utils.BaseName("")
+ Expect(result).To(Equal(""))
+ })
+
+ It("handles path ending with separator", func() {
+ result := utils.BaseName("/path/to/dir/")
+ Expect(result).To(Equal("dir"))
+ })
+
+ It("handles complex nested path", func() {
+ result := utils.BaseName("/very/long/path/to/my/favorite/song.mp3")
+ Expect(result).To(Equal("song"))
+ })
+})
+
+var _ = Describe("FileExists", func() {
+ var tempFile *os.File
+ var tempDir string
+
+ BeforeEach(func() {
+ var err error
+ tempFile, err = os.CreateTemp("", "fileexists-test-*.txt")
+ Expect(err).NotTo(HaveOccurred())
+
+ tempDir, err = os.MkdirTemp("", "fileexists-test-dir-*")
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ AfterEach(func() {
+ if tempFile != nil {
+ os.Remove(tempFile.Name())
+ tempFile.Close()
+ }
+ if tempDir != "" {
+ os.RemoveAll(tempDir)
+ }
+ })
+
+ It("returns true for existing file", func() {
+ Expect(utils.FileExists(tempFile.Name())).To(BeTrue())
+ })
+
+ It("returns true for existing directory", func() {
+ Expect(utils.FileExists(tempDir)).To(BeTrue())
+ })
+
+ It("returns false for non-existing file", func() {
+ nonExistentPath := filepath.Join(tempDir, "does-not-exist.txt")
+ Expect(utils.FileExists(nonExistentPath)).To(BeFalse())
+ })
+
+ It("returns false for empty path", func() {
+ Expect(utils.FileExists("")).To(BeFalse())
+ })
+
+ It("handles nested non-existing path", func() {
+ nonExistentPath := "/this/path/definitely/does/not/exist/file.txt"
+ Expect(utils.FileExists(nonExistentPath)).To(BeFalse())
+ })
+
+ Context("when file is deleted after creation", func() {
+ It("returns false after file deletion", func() {
+ filePath := tempFile.Name()
+ Expect(utils.FileExists(filePath)).To(BeTrue())
+
+ err := os.Remove(filePath)
+ Expect(err).NotTo(HaveOccurred())
+ tempFile = nil // Prevent cleanup attempt
+
+ Expect(utils.FileExists(filePath)).To(BeFalse())
+ })
+ })
+
+ Context("when directory is deleted after creation", func() {
+ It("returns false after directory deletion", func() {
+ dirPath := tempDir
+ Expect(utils.FileExists(dirPath)).To(BeTrue())
+
+ err := os.RemoveAll(dirPath)
+ Expect(err).NotTo(HaveOccurred())
+ tempDir = "" // Prevent cleanup attempt
+
+ Expect(utils.FileExists(dirPath)).To(BeFalse())
+ })
+ })
+
+ It("handles permission denied scenarios gracefully", func() {
+ // This test might be platform specific, but we test the general case
+ result := utils.FileExists("/root/.ssh/id_rsa") // Likely to not exist or be inaccessible
+ Expect(result).To(Or(BeTrue(), BeFalse())) // Should not panic
+ })
+})