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,