feat(library): implement library selection functionality with reducer and UI component

Signed-off-by: Deluan <deluan@navidrome.org>

# Conflicts:
#	.github/copilot-instructions.md

# Conflicts:
#	.gitignore
This commit is contained in:
Deluan 2025-06-26 00:45:27 -04:00
commit 1713396da2
11 changed files with 307 additions and 53 deletions

View file

@ -1,53 +0,0 @@
# Navidrome Code Guidelines
This is a music streaming server written in Go with a React frontend. The application manages music libraries, provides streaming capabilities, and offers various features like artist information, artwork handling, and external service integrations.
## Code Standards
### Backend (Go)
- Follow standard Go conventions and idioms
- Use context propagation for cancellation signals
- Write unit tests for new functionality using Ginkgo/Gomega
- Use mutex appropriately for concurrent operations
- Implement interfaces for dependencies to facilitate testing
### Frontend (React)
- Use functional components with hooks
- Follow React best practices for state management
- Implement PropTypes for component properties
- Prefer using React-Admin and Material-UI components
- Icons should be imported from `react-icons` only
- Follow existing patterns for API interaction
## Repository Structure
- `core/`: Server-side business logic (artwork handling, playback, etc.)
- `ui/`: React frontend components
- `model/`: Data models and repository interfaces
- `server/`: API endpoints and server implementation
- `utils/`: Shared utility functions
- `persistence/`: Database access layer
- `scanner/`: Music library scanning functionality
## Key Guidelines
1. Maintain cache management patterns for performance
2. Follow the existing concurrency patterns (mutex, atomic)
3. Use the testing framework appropriately (Ginkgo/Gomega for Go)
4. Keep UI components focused and reusable
5. Document configuration options in code
6. Consider performance implications when working with music libraries
7. Follow existing error handling patterns
8. Ensure compatibility with external services (LastFM, Spotify, Deezer)
## Development Workflow
- Test changes thoroughly, especially around concurrent operations
- Validate both backend and frontend interactions
- Consider how changes will affect user experience and performance
- Test with different music library sizes and configurations
- Before committing, ALWAYS run `make format lint test`, and make sure there are no issues
## Important commands
- `make build`: Build the application
- `make test`: Run Go tests
- To run tests for a specific package, use `make test PKG=./pkgname/...`
- `make lintall`: Run linters
- `make format`: Format code

View file

@ -19,6 +19,7 @@ import library from './library'
import { Player } from './audioplayer'
import customRoutes from './routes'
import {
libraryReducer,
themeReducer,
addToPlaylistDialogReducer,
expandInfoDialogReducer,
@ -57,6 +58,7 @@ const adminStore = createAdminStore({
dataProvider,
history,
customReducers: {
library: libraryReducer,
player: playerReducer,
albumView: albumViewReducer,
theme: themeReducer,

View file

@ -1,3 +1,4 @@
export * from './library'
export * from './player'
export * from './themes'
export * from './albumView'

12
ui/src/actions/library.js Normal file
View file

@ -0,0 +1,12 @@
export const SET_SELECTED_LIBRARIES = 'SET_SELECTED_LIBRARIES'
export const SET_USER_LIBRARIES = 'SET_USER_LIBRARIES'
export const setSelectedLibraries = (libraryIds) => ({
type: SET_SELECTED_LIBRARIES,
data: libraryIds,
})
export const setUserLibraries = (libraries) => ({
type: SET_USER_LIBRARIES,
data: libraries,
})

View file

@ -0,0 +1,208 @@
import React, { useState, useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useDataProvider, useTranslate } 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'
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), // Less rounded corners to better match search input
height: theme.spacing(4.8), // Slightly reduce height to better match search input
fontSize: '1rem', // Match sidebar menu items font size
fontWeight: 'normal', // Match sidebar menu items font weight
minWidth: '210px', // Ensure minimum width
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', // Match the default Material-UI checkbox padding
marginLeft: '-9px', // Negative margin to align with FormControlLabel checkboxes
marginRight: 0,
},
}))
const LibrarySelector = () => {
const classes = useStyles()
const dispatch = useDispatch()
const dataProvider = useDataProvider()
const translate = useTranslate()
const [anchorEl, setAnchorEl] = useState(null)
const [open, setOpen] = useState(false)
const { userLibraries, selectedLibraries } = useSelector(
(state) => state.library,
)
// Load user's libraries when component mounts
useEffect(() => {
const loadUserLibraries = async () => {
// Get userId from localStorage (not from identity which returns username)
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,
)
}
}
}
loadUserLibraries()
}, [dataProvider, dispatch])
// 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)
setOpen(!open)
}
const handleClose = () => {
setOpen(false)
}
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 (
<Box className={classes.root}>
<Chip
icon={<LibraryMusic />}
label={displayText}
onClick={handleToggle}
onDelete={open ? handleToggle : undefined}
deleteIcon={open ? <ExpandLess /> : <ExpandMore />}
variant="outlined"
clickable
className={classes.chip}
/>
<Popper
open={open}
anchorEl={anchorEl}
placement="bottom-start"
className={classes.popper}
>
<ClickAwayListener onClickAway={handleClose}>
<Paper className={classes.paper}>
<Box className={classes.headerContainer}>
<Checkbox
checked={isAllSelected}
indeterminate={isIndeterminate}
onChange={handleMasterCheckboxChange}
size="small"
className={classes.masterCheckbox}
/>
<Typography>
{translate('menu.librarySelector.selectLibraries')}:
</Typography>
</Box>
<FormControl component="fieldset" variant="standard" fullWidth>
<FormGroup>
{userLibraries.map((library) => (
<FormControlLabel
key={library.id}
control={
<Checkbox
checked={selectedLibraries.includes(library.id)}
onChange={() => handleLibraryToggle(library.id)}
size="small"
/>
}
label={library.name}
/>
))}
</FormGroup>
</FormControl>
</Paper>
</ClickAwayListener>
</Popper>
</Box>
)
}
export default LibrarySelector

View file

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

View file

@ -517,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",

View file

@ -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 && <LibrarySelector />}
<SubMenu
handleToggle={() => handleToggle('menuAlbumList')}
isOpen={state.menuAlbumList}

View file

@ -1,3 +1,4 @@
export * from './libraryReducer'
export * from './themeReducer'
export * from './dialogReducer'
export * from './playerReducer'

View file

@ -0,0 +1,30 @@
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
? data.map((lib) => lib.id)
: previousState.selectedLibraries,
}
case SET_SELECTED_LIBRARIES:
return {
...previousState,
selectedLibraries: data,
}
default:
return previousState
}
}

View file

@ -57,6 +57,7 @@ const createAdminStore = ({
const state = store.getState()
saveState({
theme: state.theme,
library: state.library,
player: (({ queue, volume, savedPlayIndex }) => ({
queue,
volume,