diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 451ffb2bd..000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -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 diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 1c613f133..dc4fe9b53 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -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, diff --git a/ui/src/actions/index.js b/ui/src/actions/index.js index a319f7a69..9f35f86a9 100644 --- a/ui/src/actions/index.js +++ b/ui/src/actions/index.js @@ -1,3 +1,4 @@ +export * from './library' export * from './player' export * from './themes' export * from './albumView' diff --git a/ui/src/actions/library.js b/ui/src/actions/library.js new file mode 100644 index 000000000..4653ec739 --- /dev/null +++ b/ui/src/actions/library.js @@ -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, +}) diff --git a/ui/src/common/LibrarySelector.jsx b/ui/src/common/LibrarySelector.jsx new file mode 100644 index 000000000..16baaa949 --- /dev/null +++ b/ui/src/common/LibrarySelector.jsx @@ -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 ( + + } + label={displayText} + onClick={handleToggle} + onDelete={open ? handleToggle : undefined} + deleteIcon={open ? : } + variant="outlined" + clickable + 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/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/i18n/en.json b/ui/src/i18n/en.json index 3def7dcc5..9525b878d 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -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", 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/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..f96f47e12 --- /dev/null +++ b/ui/src/reducers/libraryReducer.js @@ -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 + } +} 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,