feat: Multi-library support (#4181)

* feat(database): add user_library table and library access methods

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

# Conflicts:
#	tests/mock_library_repo.go

* feat(database): enhance user retrieval with library associations

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

* feat(api): implement library management and user-library association endpoints

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

* feat(api): restrict access to library and config endpoints to admin users

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

* refactor(library): implement library management service and update API routes

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

* feat(database): add library filtering to album, folder, and media file queries

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

* refactor library service to use REST repository pattern and remove CRUD operations

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

* add total_duration column to library and update user_library table

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

* fix migration file name

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

* feat(library): add library management features including create, edit, delete, and list functionalities - WIP

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

* feat(library): enhance library validation and management with path checks and normalization - WIP

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

* feat(library): improve library path validation and error handling - WIP

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

* use utils/formatBytes

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

* simplify DeleteLibraryButton.jsx

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

* feat(library): enhance validation messages and error handling for library paths

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

* lint

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

* test(scanner): add tests for multi-library scanning and validation

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

* test(scanner): improve handling of filesystem errors and ensure warnings are returned

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

* feat(controller): add function to retrieve the most recent scan time across all libraries

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

* feat(library): add additional fields and restructure LibraryEdit component for enhanced statistics display

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

* feat(library): enhance LibraryCreate and LibraryEdit components with additional props and styling

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

* feat(mediafile): add LibraryName field and update queries to include library name

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

* feat(missingfiles): add library filter and display in MissingFilesList component

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

* feat(library): implement scanner interface for triggering library scans on create/update

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

# Conflicts:
#	cmd/wire_gen.go
#	cmd/wire_injectors.go

# Conflicts:
#	cmd/wire_gen.go

# Conflicts:
#	cmd/wire_gen.go
#	cmd/wire_injectors.go

* feat(library): trigger scan after successful library deletion to clean up orphaned data

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

* rename migration file for user library table to maintain versioning order

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

* refactor: move scan triggering logic into a helper method for clarity

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

* feat(library): add library path and name fields to album and mediafile models

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

* feat(library): add/remove watchers on demand, not only when server starts

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

* refactor(scanner): streamline library handling by using state-libraries for consistency

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

* fix: track processed libraries by updating state with scan timestamps

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

* prepend libraryID for track and album PIDs

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

* feat(repository): apply library filtering in CountAll methods for albums, folders, and media files

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

* feat(user): add library selection for user creation and editing

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

* 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

* feat(library): add tests for LibrarySelector and library selection hooks

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

* test: add unit tests for file utility functions

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

* feat(library): add library ID filtering for album resources

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

* feat(library): streamline library ID filtering in repositories and update resource filtering logic

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

* fix(repository): add table name handling in filter functions for SQL queries

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

* feat(library): add refresh functionality on LibrarySelector close

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

* feat(artist): add library ID filtering for artists in repository and update resource filtering logic

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

# Conflicts:
#	persistence/artist_repository.go

* Add library_id field support for smart playlists

- Add library_id field to smart playlist criteria system
- Supports Is and IsNot operators for filtering by library ID
- Includes comprehensive test coverage for single values and lists
- Enables creation of library-specific smart playlists

* feat(subsonic): implement user-specific library access in GetMusicFolders

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

* feat(library): enhance LibrarySelectionField to extract library IDs from record

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

* feat(subsonic): update GetIndexes and GetArtists method to support library ID filtering

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

* fix: ensure LibrarySelector dropdown refreshes on button close

Added refresh() call when closing the dropdown via button click to maintain
consistency with the ClickAwayListener behavior. This ensures the UI
updates properly regardless of how the dropdown is closed, fixing an
inconsistent refresh behavior between different closing methods.

The fix tracks the previous open state and calls refresh() only when
the dropdown was open and is being closed by the button click.

* refactor: simplify getUserAccessibleLibraries function and update related tests

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

* feat: enhance selectedMusicFolderIds function to handle valid music folder IDs and improve fallback logic

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

* refactor: change ArtistRepository.GetIndex to accept multiple library IDs

Updated the GetIndex method signature to accept a slice of library IDs instead of a single ID, enabling support for filtering artists across multiple libraries simultaneously.

Changes include:
- Modified ArtistRepository interface in model/artist.go
- Updated implementation in persistence/artist_repository.go with improved library filtering logic
- Refactored Subsonic API browsing.go to use new selectedMusicFolderIds helper
- Added comprehensive test coverage for multiple library scenarios
- Updated mock repository implementation for testing

This change improves flexibility for multi-library operations while maintaining backward compatibility through the selectedMusicFolderIds helper function.

* feat: add library access validation to selectedMusicFolderIds

Enhanced the selectedMusicFolderIds function to validate musicFolderId parameters
against the user's accessible libraries. Invalid library IDs (those the user
doesn't have access to) are now silently filtered out, improving security by
preventing users from accessing libraries they don't have permission for.

Changes include:
- Added validation logic to check musicFolderId parameters against user's accessible libraries
- Added slices package import for efficient validation
- Enhanced function documentation to clarify validation behavior
- Added comprehensive test cases covering validation scenarios
- Maintains backward compatibility with existing behavior

* feat: implement multi-library support for GetAlbumList and GetAlbumList2 endpoints

- Enhanced selectedMusicFolderIds helper to validate and filter library IDs
- Added ApplyLibraryFilter function in filter/filters.go for library filtering
- Updated getAlbumList to support musicFolderId parameter filtering
- Added comprehensive tests for multi-library functionality
- Supports single and multiple musicFolderId values
- Falls back to all accessible libraries when no musicFolderId provided
- Validates library access permissions for user security

* feat: implement multi-library support for GetRandomSongs, GetSongsByGenre, GetStarred, and GetStarred2

- Added multi-library filtering to GetRandomSongs endpoint using musicFolderId parameter
- Added multi-library filtering to GetSongsByGenre endpoint using musicFolderId parameter
- Enhanced GetStarred and GetStarred2 to filter artists, albums, and songs by library
- Added Options field to MockMediaFileRepo and MockArtistRepo for test compatibility
- Added comprehensive Ginkgo/Gomega tests for all new multi-library functionality
- All tests verify proper SQL filter generation and library access validation
- Supports single/multiple musicFolderId values with fallback to all accessible libraries

* refactor: optimize starred items queries with parallel execution and fix test isolation

Refactored starred items functionality by extracting common logic into getStarredItems()
method that executes artist, album, and media file queries in parallel for better performance.
This eliminates code duplication between GetStarred and GetStarred2 methods while improving
response times through concurrent database queries using run.Parallel().

Also fixed test isolation issues by adding missing auth.Init(ds) call in album lists test setup.
This resolves nil pointer dereference errors in GetStarred and GetStarred2 tests when run independently.

* fix: add ApplyArtistLibraryFilter to filter artists by associated music folders

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

* feat: add library access methods to User model

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

* feat: implement library access filtering for artist queries based on user permissions

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

* feat: enhance artist library filtering based on user permissions and optimize library ID retrieval

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

* fix: return error when any musicFolderId is invalid or inaccessible

Changed behavior from silently filtering invalid library IDs to returning
ErrorDataNotFound (code 70) when any provided musicFolderId parameter
is invalid or the user doesn't have access to it.

The error message includes the specific library number for better debugging.
This affects album/song list endpoints (getAlbumList, getRandomSongs,
getSongsByGenre, getStarred) to provide consistent error handling
across all Subsonic API endpoints.

Updated corresponding tests to expect errors instead of silent filtering.

* feat: add musicFolderId parameter support to Search2 and Search3 endpoints

Implemented musicFolderId parameter support for Subsonic API Search2 and Search3 endpoints, completing multi-library functionality across all Subsonic endpoints.

Key changes:
- Added musicFolderId parameter handling to Search2 and Search3 endpoints
- Updated search logic to filter results by specified library or all accessible libraries when parameter not provided
- Added proper error handling for invalid/inaccessible musicFolderId values
- Refactored SearchableRepository interface to support library filtering with variadic QueryOptions
- Updated repository implementations (Album, Artist, MediaFile) to handle library filtering in search operations
- Added comprehensive test coverage with robust assertions verifying library filtering works correctly
- Enhanced mock repositories to capture QueryOptions for test validation

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

* feat: refresh LibraryList on scan end

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

* fix: allow editing name of main library

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

* refactor: implement SendBroadcastMessage method for event broadcasting

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

* feat: add event broadcasting for library creation, update, and deletion

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

* feat: add useRefreshOnEvents hook for custom refresh logic on event changes

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

* feat: enhance library management with refresh event broadcasting

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

* feat: replace AddUserLibrary and RemoveUserLibrary with SetUserLibraries for better library management

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

* chore: remove commented-out genre repository code from persistence tests

* feat: enhance library selection with master checkbox functionality

Added a master checkbox to the SelectLibraryInput component, allowing users to select or deselect all libraries at once. This improves user experience by simplifying the selection process when multiple libraries are available. Additionally, updated translations in the en.json file to include a new message for selecting all libraries, ensuring consistency in user interface messaging.

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

* feat: add default library assignment for new users

Introduced a new column `default_new_users` in the library table to
facilitate automatic assignment of default libraries to new regular users.
When a new user is created, they will now be assigned to libraries marked
as default, enhancing user experience by ensuring they have immediate access
to essential resources. Additionally, updated the user repository logic
to handle this new functionality and modified the user creation validation
to reflect that library selection is optional for non-admin users.

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

* fix: correct updated_at assignment in library repository

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

* fix: improve cache buffering logic

Refactored the cache buffering logic to ensure thread safety when checking
the buffer length

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

* fix formating

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

* feat: implement per-library artist statistics with automatic aggregation

Implemented comprehensive multi-library support for artist statistics that
automatically aggregates stats from user-accessible libraries. This fundamental
change moves artist statistics from global scope to per-library granularity
while maintaining backward compatibility and transparent operation.

Key changes include:
- Migrated artist statistics from global artist.stats to per-library library_artist.stats
- Added automatic library filtering and aggregation in existing Get/GetAll methods
- Updated role-based filtering to work with per-library statistics storage
- Enhanced statistics calculation to process and store stats per library
- Implemented user permission-aware aggregation that respects library access control
- Added comprehensive test coverage for library filtering and restricted user access
- Created helper functions to ensure proper library associations in tests

This enables users to see statistics that accurately reflect only the content
from libraries they have access to, providing proper multi-tenant behavior
while maintaining the existing API surface and UI functionality.

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

* feat: add multi-library support with per-library tag statistics - WIP

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

* refactor: genre and tag repositories. add comprehensive tests

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

* feat: add multi-library support to tag repository system

Implemented comprehensive library filtering for tag repositories to support the multi-library feature. This change ensures that users only see tags from libraries they have access to, while admin users can see all tags.

Key changes:
- Enhanced TagRepository.Add() method to accept libraryID parameter for proper library association
- Updated baseTagRepository to implement library-aware queries with proper joins
- Added library_tag table integration for per-library tag statistics
- Implemented user permission-based filtering through user_library associations
- Added comprehensive test coverage for library filtering scenarios
- Updated UI data provider to include tag filtering by selected libraries
- Modified scanner to pass library ID when adding tags during folder processing

The implementation maintains backward compatibility while providing proper isolation between libraries for tag-based operations like genres and other metadata tags.

* refactor: simplify artist repository library filtering

Removed conditional admin logic from applyLibraryFilterToArtistQuery method
and unified the library filtering approach to match the tag repository pattern.
The method now always uses the same SQL join structure regardless of user role,
with admin access handled automatically through user_library associations.

Added artistLibraryIdFilter function to properly qualify library_id column
references and prevent SQL ambiguity errors when multiple tables contain
library_id columns. This ensures the filter targets library_artist.library_id
specifically rather than causing ambiguous column name conflicts.

* fix: resolve LibrarySelectionField validation error for non-admin users

Fixed validation error 'At least one library must be selected for non-admin users' that appeared even when libraries were selected. The issue was caused by a data format mismatch between backend and frontend.

The backend sends user data with libraries as an array of objects, but the LibrarySelectionField component expects libraryIds as an array of IDs. Added data transformation in the data provider's getOne method to automatically convert libraries array to libraryIds format when fetching user records.

Also extracted validation logic into a separate userValidation module for better code organization and added comprehensive test coverage to prevent similar issues.

* refactor: remove unused library access functions and related tests

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

* refactor: rename search_test.go to searching_test.go for consistency

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

* fix: add user context to scrobble buffer getParticipants call

Added user context handling to scrobbleBufferRepository.Next method to resolve
SQL error 'no such column: library_artist.library_id' when processing scrobble
entries in multi-library environments. The artist repository now requires user
context for proper library filtering, so we fetch the user and temporarily
inject it into the context before calling getParticipants. This ensures
background scrobbling operations work correctly with multi-library support.

* feat: add cross-library move detection for scanner

Implemented cross-library move detection for the scanner phase 2 to properly handle files moved between libraries. This prevents users from losing play counts, ratings, and other metadata when moving files across library boundaries.

Changes include:
- Added MediaFileRepository methods for two-tier matching: FindRecentFilesByMBZTrackID (primary) and FindRecentFilesByProperties (fallback)
- Extended scanner phase 2 pipeline with processCrossLibraryMoves stage that processes files unmatched within their library
- Implemented findCrossLibraryMatch with MusicBrainz Release Track ID priority and intrinsic properties fallback
- Updated producer logic to handle missing tracks without matches, ensuring cross-library processing
- Updated tests to reflect new producer behavior and cross-library functionality

The implementation uses existing moveMatched function for unified move operations, automatically preserving all user data through database foreign key relationships. Cross-library moves are detected using the same Equals() and IsEquivalent() matching logic as within-library moves for consistency.

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

* feat: add album annotation reassignment for cross-library moves

Implemented album annotation reassignment functionality for the scanner's missing tracks phase. When tracks move between libraries and change album IDs, the system now properly reassigns album annotations (starred status, ratings) from the old album to the new album. This prevents loss of user annotations when tracks are moved across library boundaries.

The implementation includes:
- Thread-safe annotation reassignment using mutex protection
- Duplicate reassignment prevention through processed album tracking
- Graceful error handling that doesn't fail the entire move operation
- Comprehensive test coverage for various scenarios including error conditions

This enhancement ensures data integrity and user experience continuity during cross-library media file movements.

* fix: address PR review comments for multi-library support

Fixed several issues identified in PR review:

- Removed unnecessary artist stats initialization check since the map is already initialized in PostScan()
- Improved code clarity in user repository by extracting isNewUser variable to avoid checking count == 0 twice
- Fixed library selection logic to properly handle initial library state and prevent overriding user selections

These changes address code quality and logic issues identified during the multi-library support PR review.

* feat: add automatic playlist statistics refreshing

Implemented automatic playlist statistics (duration, size, song count) refreshing
when tracks are modified. Added new refreshStats() method to recalculate
statistics from playlist tracks, and SetTracks() method to update tracks
and refresh statistics atomically. Modified all track manipulation methods
(RemoveTracks, AddTracks, AddMediaFiles) to automatically refresh statistics.
Updated playlist repository to use the new SetTracks method for consistent
statistics handling.

* refactor: rename AddTracks to AddMediaFilesByID for clarity

Renamed the AddTracks method to AddMediaFilesByID throughout the codebase
to better reflect its purpose of adding media files to a playlist by their IDs.
This change improves code readability and makes the method name more descriptive
of its actual functionality. Updated all references in playlist model, tests,
core playlist logic, and Subsonic API handlers to use the new method name.

* refactor: consolidate user context access in persistence layer

Removed duplicate helper functions userId() and isAdmin() from sql_base_repository.go and consolidated all user context access to use loggedUser(r.ctx).ID and loggedUser(r.ctx).IsAdmin consistently across the persistence layer.

This change eliminates code duplication and provides a single, consistent pattern for accessing user context information in repository methods. All functionality remains unchanged - this is purely a code cleanup refactoring.

* refactor: eliminate MockLibraryService duplication using embedded struct

- Replace 235-line MockLibraryService with 40-line embedded struct pattern
- Enhance MockLibraryRepo with service-layer methods (192→310 lines)
- Maintain full compatibility with existing tests
- All 72 nativeapi specs pass with proper error handling

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

* refactor: cleanup

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2025-07-18 18:41:12 -04:00
committed by GitHub
parent 089dbe9499
commit 00c83af170
127 changed files with 12196 additions and 959 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

@@ -110,7 +110,7 @@ func mainContext(ctx context.Context) (context.Context, context.CancelFunc) {
func startServer(ctx context.Context) func() error { func startServer(ctx context.Context) func() error {
return func() error { return func() error {
a := CreateServer() a := CreateServer()
a.MountRouter("Native API", consts.URLPathNativeAPI, CreateNativeAPIRouter()) a.MountRouter("Native API", consts.URLPathNativeAPI, CreateNativeAPIRouter(ctx))
a.MountRouter("Subsonic API", consts.URLPathSubsonicAPI, CreateSubsonicAPIRouter(ctx)) a.MountRouter("Subsonic API", consts.URLPathSubsonicAPI, CreateSubsonicAPIRouter(ctx))
a.MountRouter("Public Endpoints", consts.URLPathPublic, CreatePublicRouter()) a.MountRouter("Public Endpoints", consts.URLPathPublic, CreatePublicRouter())
if conf.Server.LastFM.Enabled { if conf.Server.LastFM.Enabled {

View File

@@ -52,13 +52,25 @@ func CreateServer() *server.Server {
return serverServer return serverServer
} }
func CreateNativeAPIRouter() *nativeapi.Router { func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
sqlDB := db.Db() sqlDB := db.Db()
dataStore := persistence.New(sqlDB) dataStore := persistence.New(sqlDB)
share := core.NewShare(dataStore) share := core.NewShare(dataStore)
playlists := core.NewPlaylists(dataStore) playlists := core.NewPlaylists(dataStore)
insights := metrics.GetInstance(dataStore) insights := metrics.GetInstance(dataStore)
router := nativeapi.New(dataStore, share, playlists, insights) fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
broker := events.GetBroker()
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, scannerScanner)
library := core.NewLibrary(dataStore, scannerScanner, watcher, broker)
router := nativeapi.New(dataStore, share, playlists, insights, library)
return router return router
} }
@@ -164,7 +176,7 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
broker := events.GetBroker() broker := events.GetBroker()
playlists := core.NewPlaylists(dataStore) playlists := core.NewPlaylists(dataStore)
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
watcher := scanner.NewWatcher(dataStore, scannerScanner) watcher := scanner.GetWatcher(dataStore, scannerScanner)
return watcher return watcher
} }
@@ -185,7 +197,7 @@ func getPluginManager() plugins.Manager {
// wire_injectors.go: // wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.NewWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager))) var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), wire.Bind(new(core.Scanner), new(scanner.Scanner)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
func GetPluginManager(ctx context.Context) plugins.Manager { func GetPluginManager(ctx context.Context) plugins.Manager {
manager := getPluginManager() manager := getPluginManager()

View File

@@ -38,12 +38,14 @@ var allProviders = wire.NewSet(
listenbrainz.NewRouter, listenbrainz.NewRouter,
events.GetBroker, events.GetBroker,
scanner.New, scanner.New,
scanner.NewWatcher, scanner.GetWatcher,
plugins.GetManager, plugins.GetManager,
metrics.GetPrometheusInstance, metrics.GetPrometheusInstance,
db.Db, db.Db,
wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(agents.PluginLoader), new(plugins.Manager)),
wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)),
wire.Bind(new(core.Scanner), new(scanner.Scanner)),
wire.Bind(new(core.Watcher), new(scanner.Watcher)),
) )
func CreateDataStore() model.DataStore { func CreateDataStore() model.DataStore {
@@ -58,7 +60,7 @@ func CreateServer() *server.Server {
)) ))
} }
func CreateNativeAPIRouter() *nativeapi.Router { func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
panic(wire.Build( panic(wire.Build(
allProviders, allProviders,
)) ))

View File

@@ -96,8 +96,11 @@ func (a *cacheWarmer) run(ctx context.Context) {
// If cache not available, keep waiting // If cache not available, keep waiting
if !a.cache.Available(ctx) { if !a.cache.Available(ctx) {
if len(a.buffer) > 0 { a.mutex.Lock()
log.Trace(ctx, "Cache not available, buffering precache request", "bufferLen", len(a.buffer)) bufferLen := len(a.buffer)
a.mutex.Unlock()
if bufferLen > 0 {
log.Trace(ctx, "Cache not available, buffering precache request", "bufferLen", bufferLen)
} }
continue continue
} }

View File

@@ -80,6 +80,7 @@ var _ = Describe("CacheWarmer", func() {
}) })
It("adds multiple items to buffer", func() { It("adds multiple items to buffer", func() {
fc.SetReady(false) // Make cache unavailable so items stay in buffer
cw := NewCacheWarmer(aw, fc).(*cacheWarmer) cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-1")) cw.PreCache(model.MustParseArtworkID("al-1"))
cw.PreCache(model.MustParseArtworkID("al-2")) cw.PreCache(model.MustParseArtworkID("al-2"))
@@ -214,3 +215,7 @@ func (f *mockFileCache) SetDisabled(v bool) {
f.disabled.Store(v) f.disabled.Store(v)
f.ready.Store(true) f.ready.Store(true)
} }
func (f *mockFileCache) SetReady(v bool) {
f.ready.Store(v)
}

412
core/library.go Normal file
View File

@@ -0,0 +1,412 @@
package core
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/slice"
)
// Scanner interface for triggering scans
type Scanner interface {
ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error)
}
// Watcher interface for managing file system watchers
type Watcher interface {
Watch(ctx context.Context, lib *model.Library) error
StopWatching(ctx context.Context, libraryID int) error
}
// Library provides business logic for library management and user-library associations
type Library interface {
GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error)
SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error
ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error
NewRepository(ctx context.Context) rest.Repository
}
type libraryService struct {
ds model.DataStore
scanner Scanner
watcher Watcher
broker events.Broker
}
// NewLibrary creates a new Library service
func NewLibrary(ds model.DataStore, scanner Scanner, watcher Watcher, broker events.Broker) Library {
return &libraryService{
ds: ds,
scanner: scanner,
watcher: watcher,
broker: broker,
}
}
// User-library association operations
func (s *libraryService) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) {
// Verify user exists
if _, err := s.ds.User(ctx).Get(userID); err != nil {
return nil, err
}
return s.ds.User(ctx).GetUserLibraries(userID)
}
func (s *libraryService) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error {
// Verify user exists
user, err := s.ds.User(ctx).Get(userID)
if err != nil {
return err
}
// Admin users get all libraries automatically - don't allow manual assignment
if user.IsAdmin {
return fmt.Errorf("%w: cannot manually assign libraries to admin users", model.ErrValidation)
}
// Regular users must have at least one library
if len(libraryIDs) == 0 {
return fmt.Errorf("%w: at least one library must be assigned to non-admin users", model.ErrValidation)
}
// Validate all library IDs exist
if len(libraryIDs) > 0 {
if err := s.validateLibraryIDs(ctx, libraryIDs); err != nil {
return err
}
}
// Set user libraries
err = s.ds.User(ctx).SetUserLibraries(userID, libraryIDs)
if err != nil {
return fmt.Errorf("error setting user libraries: %w", err)
}
// Send refresh event to all clients
event := &events.RefreshResource{}
libIDs := slice.Map(libraryIDs, func(id int) string { return strconv.Itoa(id) })
event = event.With("user", userID).With("library", libIDs...)
s.broker.SendBroadcastMessage(ctx, event)
return nil
}
func (s *libraryService) ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error {
user, ok := request.UserFrom(ctx)
if !ok {
return fmt.Errorf("user not found in context")
}
// Admin users have access to all libraries
if user.IsAdmin {
return nil
}
// Check if user has explicit access to this library
libraries, err := s.ds.User(ctx).GetUserLibraries(userID)
if err != nil {
log.Error(ctx, "Error checking library access", "userID", userID, "libraryID", libraryID, err)
return fmt.Errorf("error checking library access: %w", err)
}
for _, lib := range libraries {
if lib.ID == libraryID {
return nil
}
}
return fmt.Errorf("%w: user does not have access to library %d", model.ErrNotAuthorized, libraryID)
}
// REST repository wrapper
func (s *libraryService) NewRepository(ctx context.Context) rest.Repository {
repo := s.ds.Library(ctx)
wrapper := &libraryRepositoryWrapper{
ctx: ctx,
LibraryRepository: repo,
Repository: repo.(rest.Repository),
ds: s.ds,
scanner: s.scanner,
watcher: s.watcher,
broker: s.broker,
}
return wrapper
}
type libraryRepositoryWrapper struct {
rest.Repository
model.LibraryRepository
ctx context.Context
ds model.DataStore
scanner Scanner
watcher Watcher
broker events.Broker
}
func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) {
lib := entity.(*model.Library)
if err := r.validateLibrary(lib); err != nil {
return "", err
}
err := r.LibraryRepository.Put(lib)
if err != nil {
return "", r.mapError(err)
}
// Start watcher and trigger scan after successful library creation
if r.watcher != nil {
if err := r.watcher.Watch(r.ctx, lib); err != nil {
log.Warn(r.ctx, "Failed to start watcher for new library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
}
}
if r.scanner != nil {
go r.triggerScan(lib, "new")
}
// Send library refresh event to all clients
if r.broker != nil {
event := &events.RefreshResource{}
r.broker.SendBroadcastMessage(r.ctx, event.With("library", strconv.Itoa(lib.ID)))
log.Debug(r.ctx, "Library created - sent refresh event", "libraryID", lib.ID, "name", lib.Name)
}
return strconv.Itoa(lib.ID), nil
}
func (r *libraryRepositoryWrapper) Update(id string, entity interface{}, cols ...string) error {
lib := entity.(*model.Library)
libID, err := strconv.Atoi(id)
if err != nil {
return fmt.Errorf("invalid library ID: %s", id)
}
lib.ID = libID
if err := r.validateLibrary(lib); err != nil {
return err
}
// Get the original library to check if path changed
originalLib, err := r.Get(libID)
if err != nil {
return r.mapError(err)
}
pathChanged := originalLib.Path != lib.Path
err = r.LibraryRepository.Put(lib)
if err != nil {
return r.mapError(err)
}
// Restart watcher and trigger scan if path was updated
if pathChanged {
if r.watcher != nil {
if err := r.watcher.Watch(r.ctx, lib); err != nil {
log.Warn(r.ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
}
}
if r.scanner != nil {
go r.triggerScan(lib, "updated")
}
}
// Send library refresh event to all clients
if r.broker != nil {
event := &events.RefreshResource{}
r.broker.SendBroadcastMessage(r.ctx, event.With("library", id))
log.Debug(r.ctx, "Library updated - sent refresh event", "libraryID", libID, "name", lib.Name)
}
return nil
}
func (r *libraryRepositoryWrapper) Delete(id string) error {
libID, err := strconv.Atoi(id)
if err != nil {
return &rest.ValidationError{Errors: map[string]string{
"id": "invalid library ID format",
}}
}
// Get library info before deletion for logging
lib, err := r.Get(libID)
if err != nil {
return r.mapError(err)
}
err = r.LibraryRepository.Delete(libID)
if err != nil {
return r.mapError(err)
}
// Stop watcher and trigger scan after successful library deletion to clean up orphaned data
if r.watcher != nil {
if err := r.watcher.StopWatching(r.ctx, libID); err != nil {
log.Warn(r.ctx, "Failed to stop watcher for deleted library", "libraryID", libID, "name", lib.Name, "path", lib.Path, err)
}
}
if r.scanner != nil {
go r.triggerScan(lib, "deleted")
}
// Send library refresh event to all clients
if r.broker != nil {
event := &events.RefreshResource{}
r.broker.SendBroadcastMessage(r.ctx, event.With("library", id))
log.Debug(r.ctx, "Library deleted - sent refresh event", "libraryID", libID, "name", lib.Name)
}
return nil
}
// Helper methods
func (r *libraryRepositoryWrapper) mapError(err error) error {
if err == nil {
return nil
}
errStr := err.Error()
// Handle database constraint violations.
// TODO: Being tied to react-admin translations is not ideal, but this will probably go away with the new UI/API
if strings.Contains(errStr, "UNIQUE constraint failed") {
if strings.Contains(errStr, "library.name") {
return &rest.ValidationError{Errors: map[string]string{"name": "ra.validation.unique"}}
}
if strings.Contains(errStr, "library.path") {
return &rest.ValidationError{Errors: map[string]string{"path": "ra.validation.unique"}}
}
}
switch {
case errors.Is(err, model.ErrNotFound):
return rest.ErrNotFound
case errors.Is(err, model.ErrNotAuthorized):
return rest.ErrPermissionDenied
default:
return err
}
}
func (r *libraryRepositoryWrapper) validateLibrary(library *model.Library) error {
validationErrors := make(map[string]string)
if library.Name == "" {
validationErrors["name"] = "ra.validation.required"
}
if library.Path == "" {
validationErrors["path"] = "ra.validation.required"
} else {
// Validate path format and accessibility
if err := r.validateLibraryPath(library); err != nil {
validationErrors["path"] = err.Error()
}
}
if len(validationErrors) > 0 {
return &rest.ValidationError{Errors: validationErrors}
}
return nil
}
func (r *libraryRepositoryWrapper) validateLibraryPath(library *model.Library) error {
// Validate path format
if !filepath.IsAbs(library.Path) {
return fmt.Errorf("library path must be absolute")
}
// Clean the path to normalize it
cleanPath := filepath.Clean(library.Path)
library.Path = cleanPath
// Check if path exists and is accessible using storage abstraction
fileStore, err := storage.For(library.Path)
if err != nil {
return fmt.Errorf("invalid storage scheme: %w", err)
}
fsys, err := fileStore.FS()
if err != nil {
log.Warn(r.ctx, "Error validating library.path", "path", library.Path, err)
return fmt.Errorf("resources.library.validation.pathInvalid")
}
// Check if root directory exists
info, err := fs.Stat(fsys, ".")
if err != nil {
// Parse the error message to check for "not a directory"
log.Warn(r.ctx, "Error stating library.path", "path", library.Path, err)
errStr := err.Error()
if strings.Contains(errStr, "not a directory") ||
strings.Contains(errStr, "The directory name is invalid.") {
return fmt.Errorf("resources.library.validation.pathNotDirectory")
} else if os.IsNotExist(err) {
return fmt.Errorf("resources.library.validation.pathNotFound")
} else if os.IsPermission(err) {
return fmt.Errorf("resources.library.validation.pathNotAccessible")
} else {
return fmt.Errorf("resources.library.validation.pathInvalid")
}
}
if !info.IsDir() {
return fmt.Errorf("resources.library.validation.pathNotDirectory")
}
return nil
}
func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []int) error {
if len(libraryIDs) == 0 {
return nil
}
// Use CountAll to efficiently validate library IDs exist
count, err := s.ds.Library(ctx).CountAll(model.QueryOptions{
Filters: squirrel.Eq{"id": libraryIDs},
})
if err != nil {
return fmt.Errorf("error validating library IDs: %w", err)
}
if int(count) != len(libraryIDs) {
return fmt.Errorf("%w: one or more library IDs are invalid", model.ErrValidation)
}
return nil
}
func (r *libraryRepositoryWrapper) triggerScan(lib *model.Library, action string) {
log.Info(r.ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
start := time.Now()
warnings, err := r.scanner.ScanAll(r.ctx, false) // Quick scan for new library
if err != nil {
log.Error(r.ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err)
} else {
log.Info(r.ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start))
}
}

980
core/library_test.go Normal file
View File

@@ -0,0 +1,980 @@
package core_test
import (
"context"
"errors"
"net/http"
"os"
"path/filepath"
"sync"
"github.com/deluan/rest"
_ "github.com/navidrome/navidrome/adapters/taglib" // Register taglib extractor
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
_ "github.com/navidrome/navidrome/core/storage/local" // Register local storage
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// These tests require the local storage adapter and the taglib extractor to be registered.
var _ = Describe("Library Service", func() {
var service core.Library
var ds *tests.MockDataStore
var libraryRepo *tests.MockLibraryRepo
var userRepo *tests.MockedUserRepo
var ctx context.Context
var tempDir string
var scanner *mockScanner
var watcherManager *mockWatcherManager
var broker *mockEventBroker
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ds = &tests.MockDataStore{}
libraryRepo = &tests.MockLibraryRepo{}
userRepo = tests.CreateMockUserRepo()
ds.MockedLibrary = libraryRepo
ds.MockedUser = userRepo
// Create a mock scanner that tracks calls
scanner = &mockScanner{}
// Create a mock watcher manager
watcherManager = &mockWatcherManager{
libraryStates: make(map[int]model.Library),
}
// Create a mock event broker
broker = &mockEventBroker{}
service = core.NewLibrary(ds, scanner, watcherManager, broker)
ctx = context.Background()
// Create a temporary directory for testing valid paths
var err error
tempDir, err = os.MkdirTemp("", "navidrome-library-test-")
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() {
os.RemoveAll(tempDir)
})
})
Describe("Library CRUD Operations", func() {
var repo rest.Persistable
BeforeEach(func() {
r := service.NewRepository(ctx)
repo = r.(rest.Persistable)
})
Describe("Create", func() {
It("creates a new library successfully", func() {
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
_, err := repo.Save(library)
Expect(err).NotTo(HaveOccurred())
Expect(libraryRepo.Data[1].Name).To(Equal("New Library"))
Expect(libraryRepo.Data[1].Path).To(Equal(tempDir))
})
It("fails when library name is empty", func() {
library := &model.Library{Path: tempDir}
_, err := repo.Save(library)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("ra.validation.required"))
})
It("fails when library path is empty", func() {
library := &model.Library{Name: "Test"}
_, err := repo.Save(library)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("ra.validation.required"))
})
It("fails when library path is not absolute", func() {
library := &model.Library{Name: "Test", Path: "relative/path"}
_, err := repo.Save(library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute"))
})
Context("Database constraint violations", func() {
BeforeEach(func() {
// Set up an existing library that will cause constraint violations
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Existing Library", Path: tempDir},
})
})
AfterEach(func() {
// Reset custom PutFn after each test
libraryRepo.PutFn = nil
})
It("handles name uniqueness constraint violation from database", func() {
// Create the directory that will be used for the test
otherTempDir, err := os.MkdirTemp("", "navidrome-other-")
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() { os.RemoveAll(otherTempDir) })
// Try to create another library with the same name
library := &model.Library{ID: 2, Name: "Existing Library", Path: otherTempDir}
// Mock the repository to return a UNIQUE constraint error
libraryRepo.PutFn = func(library *model.Library) error {
return errors.New("UNIQUE constraint failed: library.name")
}
_, err = repo.Save(library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors["name"]).To(Equal("ra.validation.unique"))
})
It("handles path uniqueness constraint violation from database", func() {
// Try to create another library with the same path
library := &model.Library{ID: 2, Name: "Different Library", Path: tempDir}
// Mock the repository to return a UNIQUE constraint error
libraryRepo.PutFn = func(library *model.Library) error {
return errors.New("UNIQUE constraint failed: library.path")
}
_, err := repo.Save(library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors["path"]).To(Equal("ra.validation.unique"))
})
})
})
Describe("Update", func() {
BeforeEach(func() {
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Original Library", Path: tempDir},
})
})
It("updates an existing library successfully", func() {
newTempDir, err := os.MkdirTemp("", "navidrome-library-update-")
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() { os.RemoveAll(newTempDir) })
library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}
err = repo.Update("1", library)
Expect(err).NotTo(HaveOccurred())
Expect(libraryRepo.Data[1].Name).To(Equal("Updated Library"))
Expect(libraryRepo.Data[1].Path).To(Equal(newTempDir))
})
It("fails when library doesn't exist", func() {
// Create a unique temporary directory to avoid path conflicts
uniqueTempDir, err := os.MkdirTemp("", "navidrome-nonexistent-")
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() { os.RemoveAll(uniqueTempDir) })
library := &model.Library{ID: 999, Name: "Non-existent", Path: uniqueTempDir}
err = repo.Update("999", library)
Expect(err).To(HaveOccurred())
Expect(err).To(Equal(model.ErrNotFound))
})
It("fails when library name is empty", func() {
library := &model.Library{ID: 1, Path: tempDir}
err := repo.Update("1", library)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("ra.validation.required"))
})
It("cleans and normalizes the path on update", func() {
unnormalizedPath := tempDir + "//../" + filepath.Base(tempDir)
library := &model.Library{ID: 1, Name: "Updated Library", Path: unnormalizedPath}
err := repo.Update("1", library)
Expect(err).NotTo(HaveOccurred())
Expect(libraryRepo.Data[1].Path).To(Equal(filepath.Clean(unnormalizedPath)))
})
It("allows updating library with same name (no change)", func() {
// Set up a library
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Test Library", Path: tempDir},
})
// Update the library keeping the same name (should be allowed)
library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir}
err := repo.Update("1", library)
Expect(err).NotTo(HaveOccurred())
})
It("allows updating library with same path (no change)", func() {
// Set up a library
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Test Library", Path: tempDir},
})
// Update the library keeping the same path (should be allowed)
library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir}
err := repo.Update("1", library)
Expect(err).NotTo(HaveOccurred())
})
Context("Database constraint violations during update", func() {
BeforeEach(func() {
// Reset any custom PutFn from previous tests
libraryRepo.PutFn = nil
})
It("handles name uniqueness constraint violation during update", func() {
// Create additional temp directory for the test
otherTempDir, err := os.MkdirTemp("", "navidrome-other-")
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() { os.RemoveAll(otherTempDir) })
// Set up two libraries
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Library One", Path: tempDir},
{ID: 2, Name: "Library Two", Path: otherTempDir},
})
// Mock database constraint violation
libraryRepo.PutFn = func(library *model.Library) error {
return errors.New("UNIQUE constraint failed: library.name")
}
// Try to update library 2 to have the same name as library 1
library := &model.Library{ID: 2, Name: "Library One", Path: otherTempDir}
err = repo.Update("2", library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors["name"]).To(Equal("ra.validation.unique"))
})
It("handles path uniqueness constraint violation during update", func() {
// Create additional temp directory for the test
otherTempDir, err := os.MkdirTemp("", "navidrome-other-")
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() { os.RemoveAll(otherTempDir) })
// Set up two libraries
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Library One", Path: tempDir},
{ID: 2, Name: "Library Two", Path: otherTempDir},
})
// Mock database constraint violation
libraryRepo.PutFn = func(library *model.Library) error {
return errors.New("UNIQUE constraint failed: library.path")
}
// Try to update library 2 to have the same path as library 1
library := &model.Library{ID: 2, Name: "Library Two", Path: tempDir}
err = repo.Update("2", library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors["path"]).To(Equal("ra.validation.unique"))
})
})
})
Describe("Path Validation", func() {
Context("Create operation", func() {
It("fails when path is not absolute", func() {
library := &model.Library{Name: "Test", Path: "relative/path"}
_, err := repo.Save(library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute"))
})
It("fails when path does not exist", func() {
nonExistentPath := filepath.Join(tempDir, "nonexistent")
library := &model.Library{Name: "Test", Path: nonExistentPath}
_, err := repo.Save(library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathInvalid"))
})
It("fails when path is a file instead of directory", func() {
testFile := filepath.Join(tempDir, "testfile.txt")
err := os.WriteFile(testFile, []byte("test"), 0600)
Expect(err).NotTo(HaveOccurred())
library := &model.Library{Name: "Test", Path: testFile}
_, err = repo.Save(library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathNotDirectory"))
})
It("fails when path is not accessible due to permissions", func() {
Skip("Permission tests are environment-dependent and may fail in CI")
// This test is skipped because creating a directory with no read permissions
// is complex and may not work consistently across different environments
})
It("handles multiple validation errors", func() {
library := &model.Library{Name: "", Path: "relative/path"}
_, err := repo.Save(library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors).To(HaveKey("name"))
Expect(validationErr.Errors).To(HaveKey("path"))
Expect(validationErr.Errors["name"]).To(Equal("ra.validation.required"))
Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute"))
})
})
Context("Update operation", func() {
BeforeEach(func() {
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Test Library", Path: tempDir},
})
})
It("fails when updated path is not absolute", func() {
library := &model.Library{ID: 1, Name: "Test", Path: "relative/path"}
err := repo.Update("1", library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute"))
})
It("allows updating library with same name (no change)", func() {
// Set up a library
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Test Library", Path: tempDir},
})
// Update the library keeping the same name (should be allowed)
library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir}
err := repo.Update("1", library)
Expect(err).NotTo(HaveOccurred())
})
It("fails when updated path does not exist", func() {
nonExistentPath := filepath.Join(tempDir, "nonexistent")
library := &model.Library{ID: 1, Name: "Test", Path: nonExistentPath}
err := repo.Update("1", library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathInvalid"))
})
It("fails when updated path is a file instead of directory", func() {
testFile := filepath.Join(tempDir, "updatefile.txt")
err := os.WriteFile(testFile, []byte("test"), 0600)
Expect(err).NotTo(HaveOccurred())
library := &model.Library{ID: 1, Name: "Test", Path: testFile}
err = repo.Update("1", library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathNotDirectory"))
})
It("handles multiple validation errors on update", func() {
// Try to update with empty name and invalid path
library := &model.Library{ID: 1, Name: "", Path: "relative/path"}
err := repo.Update("1", library)
Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors).To(HaveKey("name"))
Expect(validationErr.Errors).To(HaveKey("path"))
Expect(validationErr.Errors["name"]).To(Equal("ra.validation.required"))
Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute"))
})
})
})
Describe("Delete", func() {
BeforeEach(func() {
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Library to Delete", Path: tempDir},
})
})
It("deletes an existing library successfully", func() {
err := repo.Delete("1")
Expect(err).NotTo(HaveOccurred())
Expect(libraryRepo.Data).To(HaveLen(0))
})
It("fails when library doesn't exist", func() {
err := repo.Delete("999")
Expect(err).To(HaveOccurred())
Expect(err).To(Equal(model.ErrNotFound))
})
})
})
Describe("User-Library Association Operations", func() {
var regularUser, adminUser *model.User
BeforeEach(func() {
regularUser = &model.User{ID: "user1", UserName: "regular", IsAdmin: false}
adminUser = &model.User{ID: "admin1", UserName: "admin", IsAdmin: true}
userRepo.Data = map[string]*model.User{
"regular": regularUser,
"admin": adminUser,
}
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Library 1", Path: "/music1"},
{ID: 2, Name: "Library 2", Path: "/music2"},
{ID: 3, Name: "Library 3", Path: "/music3"},
})
})
Describe("GetUserLibraries", func() {
It("returns user's libraries", func() {
userRepo.UserLibraries = map[string][]int{
"user1": {1},
}
result, err := service.GetUserLibraries(ctx, "user1")
Expect(err).NotTo(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].ID).To(Equal(1))
})
It("fails when user doesn't exist", func() {
_, err := service.GetUserLibraries(ctx, "nonexistent")
Expect(err).To(HaveOccurred())
Expect(err).To(Equal(model.ErrNotFound))
})
})
Describe("SetUserLibraries", func() {
It("sets libraries for regular user successfully", func() {
err := service.SetUserLibraries(ctx, "user1", []int{1, 2})
Expect(err).NotTo(HaveOccurred())
libraries := userRepo.UserLibraries["user1"]
Expect(libraries).To(HaveLen(2))
})
It("fails when user doesn't exist", func() {
err := service.SetUserLibraries(ctx, "nonexistent", []int{1})
Expect(err).To(HaveOccurred())
Expect(err).To(Equal(model.ErrNotFound))
})
It("fails when trying to set libraries for admin user", func() {
err := service.SetUserLibraries(ctx, "admin1", []int{1})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("cannot manually assign libraries to admin users"))
})
It("fails when no libraries provided for regular user", func() {
err := service.SetUserLibraries(ctx, "user1", []int{})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("at least one library must be assigned to non-admin users"))
})
It("fails when library doesn't exist", func() {
err := service.SetUserLibraries(ctx, "user1", []int{999})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("one or more library IDs are invalid"))
})
It("fails when some libraries don't exist", func() {
err := service.SetUserLibraries(ctx, "user1", []int{1, 999, 2})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("one or more library IDs are invalid"))
})
})
Describe("ValidateLibraryAccess", func() {
Context("admin user", func() {
BeforeEach(func() {
ctx = request.WithUser(ctx, *adminUser)
})
It("allows access to any library", func() {
err := service.ValidateLibraryAccess(ctx, "admin1", 1)
Expect(err).NotTo(HaveOccurred())
})
})
Context("regular user", func() {
BeforeEach(func() {
ctx = request.WithUser(ctx, *regularUser)
userRepo.UserLibraries = map[string][]int{
"user1": {1},
}
})
It("allows access to user's libraries", func() {
err := service.ValidateLibraryAccess(ctx, "user1", 1)
Expect(err).NotTo(HaveOccurred())
})
It("denies access to libraries user doesn't have", func() {
err := service.ValidateLibraryAccess(ctx, "user1", 2)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("user does not have access to library 2"))
})
})
Context("no user in context", func() {
It("fails with user not found error", func() {
err := service.ValidateLibraryAccess(ctx, "user1", 1)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("user not found in context"))
})
})
})
})
Describe("Scan Triggering", func() {
var repo rest.Persistable
BeforeEach(func() {
r := service.NewRepository(ctx)
repo = r.(rest.Persistable)
})
It("triggers scan when creating a new library", func() {
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
_, err := repo.Save(library)
Expect(err).NotTo(HaveOccurred())
// Wait briefly for the goroutine to complete
Eventually(func() int {
return scanner.len()
}, "1s", "10ms").Should(Equal(1))
// Verify scan was called with correct parameters
Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan
})
It("triggers scan when updating library path", func() {
// First create a library
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Original Library", Path: tempDir},
})
// Create a new temporary directory for the update
newTempDir, err := os.MkdirTemp("", "navidrome-library-update-")
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() { os.RemoveAll(newTempDir) })
// Update the library with a new path
library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}
err = repo.Update("1", library)
Expect(err).NotTo(HaveOccurred())
// Wait briefly for the goroutine to complete
Eventually(func() int {
return scanner.len()
}, "1s", "10ms").Should(Equal(1))
// Verify scan was called with correct parameters
Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan
})
It("does not trigger scan when updating library without path change", func() {
// First create a library
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Original Library", Path: tempDir},
})
// Update the library name only (same path)
library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir}
err := repo.Update("1", library)
Expect(err).NotTo(HaveOccurred())
// Wait a bit to ensure no scan was triggered
Consistently(func() int {
return scanner.len()
}, "100ms", "10ms").Should(Equal(0))
})
It("does not trigger scan when library creation fails", func() {
// Try to create library with invalid data (empty name)
library := &model.Library{Path: tempDir}
_, err := repo.Save(library)
Expect(err).To(HaveOccurred())
// Ensure no scan was triggered since creation failed
Consistently(func() int {
return scanner.len()
}, "100ms", "10ms").Should(Equal(0))
})
It("does not trigger scan when library update fails", func() {
// First create a library
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Original Library", Path: tempDir},
})
// Try to update with invalid data (empty name)
library := &model.Library{ID: 1, Name: "", Path: tempDir}
err := repo.Update("1", library)
Expect(err).To(HaveOccurred())
// Ensure no scan was triggered since update failed
Consistently(func() int {
return scanner.len()
}, "100ms", "10ms").Should(Equal(0))
})
It("triggers scan when deleting a library", func() {
// First create a library
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Library to Delete", Path: tempDir},
})
// Delete the library
err := repo.Delete("1")
Expect(err).NotTo(HaveOccurred())
// Wait briefly for the goroutine to complete
Eventually(func() int {
return scanner.len()
}, "1s", "10ms").Should(Equal(1))
// Verify scan was called with correct parameters
Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan
})
It("does not trigger scan when library deletion fails", func() {
// Try to delete a non-existent library
err := repo.Delete("999")
Expect(err).To(HaveOccurred())
// Ensure no scan was triggered since deletion failed
Consistently(func() int {
return scanner.len()
}, "100ms", "10ms").Should(Equal(0))
})
Context("Watcher Integration", func() {
It("starts watcher when creating a new library", func() {
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
_, err := repo.Save(library)
Expect(err).NotTo(HaveOccurred())
// Verify watcher was started
Eventually(func() int {
return watcherManager.lenStarted()
}, "1s", "10ms").Should(Equal(1))
Expect(watcherManager.StartedWatchers[0].ID).To(Equal(1))
Expect(watcherManager.StartedWatchers[0].Name).To(Equal("New Library"))
Expect(watcherManager.StartedWatchers[0].Path).To(Equal(tempDir))
})
It("restarts watcher when library path is updated", func() {
// First create a library
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Original Library", Path: tempDir},
})
// Simulate that this library already has a watcher
watcherManager.simulateExistingLibrary(model.Library{ID: 1, Name: "Original Library", Path: tempDir})
// Create a new temp directory for the update
newTempDir, err := os.MkdirTemp("", "navidrome-library-update-")
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() { os.RemoveAll(newTempDir) })
// Update library with new path
library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir}
err = repo.Update("1", library)
Expect(err).NotTo(HaveOccurred())
// Verify watcher was restarted
Eventually(func() int {
return watcherManager.lenRestarted()
}, "1s", "10ms").Should(Equal(1))
Expect(watcherManager.RestartedWatchers[0].ID).To(Equal(1))
Expect(watcherManager.RestartedWatchers[0].Path).To(Equal(newTempDir))
})
It("does not restart watcher when only library name is updated", func() {
// First create a library
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Original Library", Path: tempDir},
})
// Update library with same path but different name
library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir}
err := repo.Update("1", library)
Expect(err).NotTo(HaveOccurred())
// Verify watcher was NOT restarted (since path didn't change)
Consistently(func() int {
return watcherManager.lenRestarted()
}, "100ms", "10ms").Should(Equal(0))
})
It("stops watcher when library is deleted", func() {
// Set up a library
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Test Library", Path: tempDir},
})
err := repo.Delete("1")
Expect(err).NotTo(HaveOccurred())
// Verify watcher was stopped
Eventually(func() int {
return watcherManager.lenStopped()
}, "1s", "10ms").Should(Equal(1))
Expect(watcherManager.StoppedWatchers[0]).To(Equal(1))
})
It("does not stop watcher when library deletion fails", func() {
// Set up a library
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Test Library", Path: tempDir},
})
// Mock deletion to fail by trying to delete non-existent library
err := repo.Delete("999")
Expect(err).To(HaveOccurred())
// Verify watcher was NOT stopped since deletion failed
Consistently(func() int {
return watcherManager.lenStopped()
}, "100ms", "10ms").Should(Equal(0))
})
})
})
Describe("Event Broadcasting", func() {
var repo rest.Persistable
BeforeEach(func() {
r := service.NewRepository(ctx)
repo = r.(rest.Persistable)
// Clear any events from broker
broker.Events = []events.Event{}
})
It("sends refresh event when creating a library", func() {
library := &model.Library{ID: 1, Name: "New Library", Path: tempDir}
_, err := repo.Save(library)
Expect(err).NotTo(HaveOccurred())
Expect(broker.Events).To(HaveLen(1))
})
It("sends refresh event when updating a library", func() {
// First create a library
libraryRepo.SetData(model.Libraries{
{ID: 1, Name: "Original Library", Path: tempDir},
})
library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir}
err := repo.Update("1", library)
Expect(err).NotTo(HaveOccurred())
Expect(broker.Events).To(HaveLen(1))
})
It("sends refresh event when deleting a library", func() {
// First create a library
libraryRepo.SetData(model.Libraries{
{ID: 2, Name: "Library to Delete", Path: tempDir},
})
err := repo.Delete("2")
Expect(err).NotTo(HaveOccurred())
Expect(broker.Events).To(HaveLen(1))
})
})
})
// mockScanner provides a simple mock implementation of core.Scanner for testing
type mockScanner struct {
ScanCalls []ScanCall
mu sync.RWMutex
}
type ScanCall struct {
FullScan bool
}
func (m *mockScanner) ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error) {
m.mu.Lock()
defer m.mu.Unlock()
m.ScanCalls = append(m.ScanCalls, ScanCall{
FullScan: fullScan,
})
return []string{}, nil
}
func (m *mockScanner) len() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.ScanCalls)
}
// mockWatcherManager provides a simple mock implementation of core.Watcher for testing
type mockWatcherManager struct {
StartedWatchers []model.Library
StoppedWatchers []int
RestartedWatchers []model.Library
libraryStates map[int]model.Library // Track which libraries we know about
mu sync.RWMutex
}
func (m *mockWatcherManager) Watch(ctx context.Context, lib *model.Library) error {
m.mu.Lock()
defer m.mu.Unlock()
// Check if we already know about this library ID
if _, exists := m.libraryStates[lib.ID]; exists {
// This is a restart - the library already existed
// Update our tracking and record the restart
for i, startedLib := range m.StartedWatchers {
if startedLib.ID == lib.ID {
m.StartedWatchers[i] = *lib
break
}
}
m.RestartedWatchers = append(m.RestartedWatchers, *lib)
m.libraryStates[lib.ID] = *lib
return nil
}
// This is a new library - first time we're seeing it
m.StartedWatchers = append(m.StartedWatchers, *lib)
m.libraryStates[lib.ID] = *lib
return nil
}
func (m *mockWatcherManager) StopWatching(ctx context.Context, libraryID int) error {
m.mu.Lock()
defer m.mu.Unlock()
m.StoppedWatchers = append(m.StoppedWatchers, libraryID)
return nil
}
func (m *mockWatcherManager) lenStarted() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.StartedWatchers)
}
func (m *mockWatcherManager) lenStopped() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.StoppedWatchers)
}
func (m *mockWatcherManager) lenRestarted() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.RestartedWatchers)
}
// simulateExistingLibrary simulates the scenario where a library already exists
// and has a watcher running (used by tests to set up the initial state)
func (m *mockWatcherManager) simulateExistingLibrary(lib model.Library) {
m.mu.Lock()
defer m.mu.Unlock()
m.libraryStates[lib.ID] = lib
}
// mockEventBroker provides a mock implementation of events.Broker for testing
type mockEventBroker struct {
http.Handler
Events []events.Event
mu sync.RWMutex
}
func (m *mockEventBroker) SendMessage(ctx context.Context, event events.Event) {
m.mu.Lock()
defer m.mu.Unlock()
m.Events = append(m.Events, event)
}
func (m *mockEventBroker) SendBroadcastMessage(ctx context.Context, event events.Event) {
m.mu.Lock()
defer m.mu.Unlock()
m.Events = append(m.Events, event)
}

View File

@@ -0,0 +1,46 @@
package core
import (
"context"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
)
// MockLibraryWrapper provides a simple wrapper around MockLibraryRepo
// that implements the core.Library interface for testing
type MockLibraryWrapper struct {
*tests.MockLibraryRepo
}
// MockLibraryRestAdapter adapts MockLibraryRepo to rest.Repository interface
type MockLibraryRestAdapter struct {
*tests.MockLibraryRepo
}
// NewMockLibraryService creates a new mock library service for testing
func NewMockLibraryService() Library {
repo := &tests.MockLibraryRepo{
Data: make(map[int]model.Library),
}
// Set up default test data
repo.SetData(model.Libraries{
{ID: 1, Name: "Test Library 1", Path: "/music/library1"},
{ID: 2, Name: "Test Library 2", Path: "/music/library2"},
})
return &MockLibraryWrapper{MockLibraryRepo: repo}
}
func (m *MockLibraryWrapper) NewRepository(ctx context.Context) rest.Repository {
return &MockLibraryRestAdapter{MockLibraryRepo: m.MockLibraryRepo}
}
// rest.Repository interface implementation
func (a *MockLibraryRestAdapter) Delete(id string) error {
return a.DeleteByStringID(id)
}
var _ Library = (*MockLibraryWrapper)(nil)
var _ rest.Repository = (*MockLibraryRestAdapter)(nil)

View File

@@ -326,7 +326,7 @@ func (s *playlists) Update(ctx context.Context, playlistID string,
if needsTrackRefresh { if needsTrackRefresh {
pls, err = repo.GetWithTracks(playlistID, true, false) pls, err = repo.GetWithTracks(playlistID, true, false)
pls.RemoveTracks(idxToRemove) pls.RemoveTracks(idxToRemove)
pls.AddTracks(idsToAdd) pls.AddMediaFilesByID(idsToAdd)
} else { } else {
if len(idsToAdd) > 0 { if len(idsToAdd) > 0 {
_, err = tracks.Add(idsToAdd) _, err = tracks.Add(idsToAdd)

View File

@@ -74,8 +74,7 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
} }
if conf.Server.EnableNowPlaying { if conf.Server.EnableNowPlaying {
m.OnExpiration(func(_ string, _ NowPlayingInfo) { m.OnExpiration(func(_ string, _ NowPlayingInfo) {
ctx := events.BroadcastToAll(context.Background()) broker.SendBroadcastMessage(context.Background(), &events.NowPlayingCount{Count: m.Len()})
broker.SendMessage(ctx, &events.NowPlayingCount{Count: m.Len()})
}) })
} }
@@ -195,8 +194,7 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
ttl := time.Duration(remaining+5) * time.Second ttl := time.Duration(remaining+5) * time.Second
_ = p.playMap.AddWithTTL(playerId, info, ttl) _ = p.playMap.AddWithTTL(playerId, info, ttl)
if conf.Server.EnableNowPlaying { if conf.Server.EnableNowPlaying {
ctx = events.BroadcastToAll(ctx) p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
p.broker.SendMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
} }
player, _ := request.PlayerFrom(ctx) player, _ := request.PlayerFrom(ctx)
if player.ScrobbleEnabled { if player.ScrobbleEnabled {

View File

@@ -429,6 +429,12 @@ func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) {
f.events = append(f.events, event) f.events = append(f.events, event)
} }
func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) {
f.mu.Lock()
defer f.mu.Unlock()
f.events = append(f.events, event)
}
func (f *fakeEventBroker) getEvents() []events.Event { func (f *fakeEventBroker) getEvents() []events.Event {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()

View File

@@ -17,6 +17,7 @@ var Set = wire.NewSet(
NewPlayers, NewPlayers,
NewShare, NewShare,
NewPlaylists, NewPlaylists,
NewLibrary,
agents.GetAgents, agents.GetAgents,
external.NewProvider, external.NewProvider,
wire.Bind(new(external.Agents), new(*agents.Agents)), wire.Bind(new(external.Agents), new(*agents.Agents)),

View File

@@ -0,0 +1,119 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddMultiLibrarySupport, downAddMultiLibrarySupport)
}
func upAddMultiLibrarySupport(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
-- Create user_library association table
CREATE TABLE user_library (
user_id VARCHAR(255) NOT NULL,
library_id INTEGER NOT NULL,
PRIMARY KEY (user_id, library_id),
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE,
FOREIGN KEY (library_id) REFERENCES library(id) ON DELETE CASCADE
);
-- Create indexes for performance
CREATE INDEX idx_user_library_user_id ON user_library(user_id);
CREATE INDEX idx_user_library_library_id ON user_library(library_id);
-- Populate with existing users having access to library ID 1 (existing setup)
-- Admin users get access to all libraries, regular users get access to library 1
INSERT INTO user_library (user_id, library_id)
SELECT u.id, 1
FROM user u;
-- Add total_duration column to library table
ALTER TABLE library ADD COLUMN total_duration real DEFAULT 0;
UPDATE library SET total_duration = (
SELECT IFNULL(SUM(duration),0) from album where album.library_id = library.id and missing = 0
);
-- Add default_new_users column to library table
ALTER TABLE library ADD COLUMN default_new_users boolean DEFAULT false;
-- Set library ID 1 (default library) as default for new users
UPDATE library SET default_new_users = true WHERE id = 1;
-- Add stats column to library_artist junction table for per-library artist statistics
ALTER TABLE library_artist ADD COLUMN stats text DEFAULT '{}';
-- Migrate existing global artist stats to per-library format in library_artist table
-- For each library_artist association, copy the artist's global stats
UPDATE library_artist
SET stats = (
SELECT COALESCE(artist.stats, '{}')
FROM artist
WHERE artist.id = library_artist.artist_id
);
-- Remove stats column from artist table to eliminate duplication
-- Stats are now stored per-library in library_artist table
ALTER TABLE artist DROP COLUMN stats;
-- Create library_tag table for per-library tag statistics
CREATE TABLE library_tag (
tag_id VARCHAR NOT NULL,
library_id INTEGER NOT NULL,
album_count INTEGER DEFAULT 0 NOT NULL,
media_file_count INTEGER DEFAULT 0 NOT NULL,
PRIMARY KEY (tag_id, library_id),
FOREIGN KEY (tag_id) REFERENCES tag(id) ON DELETE CASCADE,
FOREIGN KEY (library_id) REFERENCES library(id) ON DELETE CASCADE
);
-- Create indexes for optimal query performance
CREATE INDEX idx_library_tag_tag_id ON library_tag(tag_id);
CREATE INDEX idx_library_tag_library_id ON library_tag(library_id);
-- Migrate existing tag stats to per-library format in library_tag table
-- For existing installations, copy current global stats to library ID 1 (default library)
INSERT INTO library_tag (tag_id, library_id, album_count, media_file_count)
SELECT t.id, 1, t.album_count, t.media_file_count
FROM tag t
WHERE EXISTS (SELECT 1 FROM library WHERE id = 1);
-- Remove global stats from tag table as they are now per-library
ALTER TABLE tag DROP COLUMN album_count;
ALTER TABLE tag DROP COLUMN media_file_count;
`)
return err
}
func downAddMultiLibrarySupport(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
-- Restore stats column to artist table before removing from library_artist
ALTER TABLE artist ADD COLUMN stats text DEFAULT '{}';
-- Restore global stats by aggregating from library_artist (simplified approach)
-- In a real rollback scenario, this might need more sophisticated logic
UPDATE artist
SET stats = (
SELECT COALESCE(la.stats, '{}')
FROM library_artist la
WHERE la.artist_id = artist.id
LIMIT 1
);
ALTER TABLE library_artist DROP COLUMN IF EXISTS stats;
DROP INDEX IF EXISTS idx_user_library_library_id;
DROP INDEX IF EXISTS idx_user_library_user_id;
DROP TABLE IF EXISTS user_library;
ALTER TABLE library DROP COLUMN IF EXISTS total_duration;
ALTER TABLE library DROP COLUMN IF EXISTS default_new_users;
-- Drop library_tag table and its indexes
DROP INDEX IF EXISTS idx_library_tag_library_id;
DROP INDEX IF EXISTS idx_library_tag_tag_id;
DROP TABLE IF EXISTS library_tag;
`)
return err
}

View File

@@ -14,6 +14,8 @@ type Album struct {
ID string `structs:"id" json:"id"` ID string `structs:"id" json:"id"`
LibraryID int `structs:"library_id" json:"libraryId"` LibraryID int `structs:"library_id" json:"libraryId"`
LibraryPath string `structs:"-" json:"libraryPath" hash:"ignore"`
LibraryName string `structs:"-" json:"libraryName" hash:"ignore"`
Name string `structs:"name" json:"name"` Name string `structs:"name" json:"name"`
EmbedArtPath string `structs:"embed_art_path" json:"-"` EmbedArtPath string `structs:"embed_art_path" json:"-"`
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated, use Participants AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated, use Participants

View File

@@ -78,7 +78,7 @@ type ArtistRepository interface {
UpdateExternalInfo(a *Artist) error UpdateExternalInfo(a *Artist) error
Get(id string) (*Artist, error) Get(id string) (*Artist, error)
GetAll(options ...QueryOptions) (Artists, error) GetAll(options ...QueryOptions) (Artists, error)
GetIndex(includeMissing bool, roles ...Role) (ArtistIndexes, error) GetIndex(includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error)
// The following methods are used exclusively by the scanner: // The following methods are used exclusively by the scanner:
RefreshPlayCounts() (int64, error) RefreshPlayCounts() (int64, error)

View File

@@ -53,6 +53,7 @@ var fieldMap = map[string]*mappedField{
"mbz_recording_id": {field: "media_file.mbz_recording_id"}, "mbz_recording_id": {field: "media_file.mbz_recording_id"},
"mbz_release_track_id": {field: "media_file.mbz_release_track_id"}, "mbz_release_track_id": {field: "media_file.mbz_release_track_id"},
"mbz_release_group_id": {field: "media_file.mbz_release_group_id"}, "mbz_release_group_id": {field: "media_file.mbz_release_group_id"},
"library_id": {field: "media_file.library_id", numeric: true},
// special fields // special fields
"random": {field: "", order: "random()"}, // pseudo-field for random sorting "random": {field: "", order: "random()"}, // pseudo-field for random sorting

View File

@@ -29,7 +29,11 @@ var _ = Describe("Operators", func() {
}, },
Entry("is [string]", Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"), Entry("is [string]", Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"),
Entry("is [bool]", Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true), Entry("is [bool]", Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true),
Entry("is [numeric]", Is{"library_id": 1}, "media_file.library_id = ?", 1),
Entry("is [numeric list]", Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2),
Entry("isNot", IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"), Entry("isNot", IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"),
Entry("isNot [numeric]", IsNot{"library_id": 1}, "media_file.library_id <> ?", 1),
Entry("isNot [numeric list]", IsNot{"library_id": []int{1, 2}}, "media_file.library_id NOT IN (?,?)", 1, 2),
Entry("gt", Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10), Entry("gt", Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10),
Entry("lt", Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10), Entry("lt", Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10),
Entry("contains", Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"), Entry("contains", Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"),

View File

@@ -8,4 +8,5 @@ var (
ErrNotAuthorized = errors.New("not authorized") ErrNotAuthorized = errors.New("not authorized")
ErrExpired = errors.New("access expired") ErrExpired = errors.New("access expired")
ErrNotAvailable = errors.New("functionality not available") ErrNotAvailable = errors.New("functionality not available")
ErrValidation = errors.New("validation error")
) )

View File

@@ -17,7 +17,7 @@ import (
type Folder struct { type Folder struct {
ID string `structs:"id"` ID string `structs:"id"`
LibraryID int `structs:"library_id"` LibraryID int `structs:"library_id"`
LibraryPath string `structs:"-" json:"-" hash:"-"` LibraryPath string `structs:"-" json:"-" hash:"ignore"`
Path string `structs:"path"` Path string `structs:"path"`
Name string `structs:"name"` Name string `structs:"name"`
ParentID string `structs:"parent_id"` ParentID string `structs:"parent_id"`

View File

@@ -2,40 +2,57 @@ package model
import ( import (
"time" "time"
"github.com/navidrome/navidrome/utils/slice"
) )
type Library struct { type Library struct {
ID int ID int `json:"id" db:"id"`
Name string Name string `json:"name" db:"name"`
Path string Path string `json:"path" db:"path"`
RemotePath string RemotePath string `json:"remotePath" db:"remote_path"`
LastScanAt time.Time LastScanAt time.Time `json:"lastScanAt" db:"last_scan_at"`
LastScanStartedAt time.Time LastScanStartedAt time.Time `json:"lastScanStartedAt" db:"last_scan_started_at"`
FullScanInProgress bool FullScanInProgress bool `json:"fullScanInProgress" db:"full_scan_in_progress"`
UpdatedAt time.Time UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
CreatedAt time.Time CreatedAt time.Time `json:"createdAt" db:"created_at"`
TotalSongs int `json:"totalSongs" db:"total_songs"`
TotalSongs int TotalAlbums int `json:"totalAlbums" db:"total_albums"`
TotalAlbums int TotalArtists int `json:"totalArtists" db:"total_artists"`
TotalArtists int TotalFolders int `json:"totalFolders" db:"total_folders"`
TotalFolders int TotalFiles int `json:"totalFiles" db:"total_files"`
TotalFiles int TotalMissingFiles int `json:"totalMissingFiles" db:"total_missing_files"`
TotalMissingFiles int TotalSize int64 `json:"totalSize" db:"total_size"`
TotalSize int64 TotalDuration float64 `json:"totalDuration" db:"total_duration"`
DefaultNewUsers bool `json:"defaultNewUsers" db:"default_new_users"`
} }
const (
DefaultLibraryID = 1
DefaultLibraryName = "Music Library"
)
type Libraries []Library type Libraries []Library
func (l Libraries) IDs() []int {
return slice.Map(l, func(lib Library) int { return lib.ID })
}
type LibraryRepository interface { type LibraryRepository interface {
Get(id int) (*Library, error) Get(id int) (*Library, error)
// GetPath returns the path of the library with the given ID. // GetPath returns the path of the library with the given ID.
// Its implementation must be optimized to avoid unnecessary queries. // Its implementation must be optimized to avoid unnecessary queries.
GetPath(id int) (string, error) GetPath(id int) (string, error)
GetAll(...QueryOptions) (Libraries, error) GetAll(...QueryOptions) (Libraries, error)
CountAll(...QueryOptions) (int64, error)
Put(*Library) error Put(*Library) error
Delete(id int) error
StoreMusicFolder() error StoreMusicFolder() error
AddArtist(id int, artistID string) error AddArtist(id int, artistID string) error
// User-library association methods
GetUsersWithLibraryAccess(libraryID int) (Users, error)
// TODO These methods should be moved to a core service // TODO These methods should be moved to a core service
ScanBegin(id int, fullScan bool) error ScanBegin(id int, fullScan bool) error
ScanEnd(id int) error ScanEnd(id int) error

View File

@@ -26,7 +26,8 @@ type MediaFile struct {
ID string `structs:"id" json:"id" hash:"ignore"` ID string `structs:"id" json:"id" hash:"ignore"`
PID string `structs:"pid" json:"-" hash:"ignore"` PID string `structs:"pid" json:"-" hash:"ignore"`
LibraryID int `structs:"library_id" json:"libraryId" hash:"ignore"` LibraryID int `structs:"library_id" json:"libraryId" hash:"ignore"`
LibraryPath string `structs:"-" json:"libraryPath" hash:"-"` LibraryPath string `structs:"-" json:"libraryPath" hash:"ignore"`
LibraryName string `structs:"-" json:"libraryName" hash:"ignore"`
FolderID string `structs:"folder_id" json:"folderId" hash:"ignore"` FolderID string `structs:"folder_id" json:"folderId" hash:"ignore"`
Path string `structs:"path" json:"path" hash:"ignore"` Path string `structs:"path" json:"path" hash:"ignore"`
Title string `structs:"title" json:"title"` Title string `structs:"title" json:"title"`
@@ -367,6 +368,8 @@ type MediaFileRepository interface {
MarkMissing(bool, ...*MediaFile) error MarkMissing(bool, ...*MediaFile) error
MarkMissingByFolder(missing bool, folderIDs ...string) error MarkMissingByFolder(missing bool, folderIDs ...string) error
GetMissingAndMatching(libId int) (MediaFileCursor, error) GetMissingAndMatching(libId int) (MediaFileCursor, error)
FindRecentFilesByMBZTrackID(missing MediaFile, since time.Time) (MediaFiles, error)
FindRecentFilesByProperties(missing MediaFile, since time.Time) (MediaFiles, error)
AnnotatedRepository AnnotatedRepository
BookmarkableRepository BookmarkableRepository

View File

@@ -14,11 +14,15 @@ import (
// These are the legacy ID functions that were used in the original Navidrome ID generation. // These are the legacy ID functions that were used in the original Navidrome ID generation.
// They are kept here for backwards compatibility with existing databases. // They are kept here for backwards compatibility with existing databases.
func legacyTrackID(mf model.MediaFile) string { func legacyTrackID(mf model.MediaFile, prependLibId bool) string {
return fmt.Sprintf("%x", md5.Sum([]byte(mf.Path))) id := mf.Path
if prependLibId && mf.LibraryID != model.DefaultLibraryID {
id = fmt.Sprintf("%d\\%s", mf.LibraryID, id)
}
return fmt.Sprintf("%x", md5.Sum([]byte(id)))
} }
func legacyAlbumID(md Metadata) string { func legacyAlbumID(mf model.MediaFile, md Metadata, prependLibId bool) string {
releaseDate := legacyReleaseDate(md) releaseDate := legacyReleaseDate(md)
albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", legacyMapAlbumArtistName(md), legacyMapAlbumName(md))) albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", legacyMapAlbumArtistName(md), legacyMapAlbumName(md)))
if !conf.Server.Scanner.GroupAlbumReleases { if !conf.Server.Scanner.GroupAlbumReleases {
@@ -26,6 +30,9 @@ func legacyAlbumID(md Metadata) string {
albumPath = fmt.Sprintf("%s\\%s", albumPath, releaseDate) albumPath = fmt.Sprintf("%s\\%s", albumPath, releaseDate)
} }
} }
if prependLibId && mf.LibraryID != model.DefaultLibraryID {
albumPath = fmt.Sprintf("%d\\%s", mf.LibraryID, albumPath)
}
return fmt.Sprintf("%x", md5.Sum([]byte(albumPath))) return fmt.Sprintf("%x", md5.Sum([]byte(albumPath)))
} }

View File

@@ -7,9 +7,9 @@ import (
"math" "math"
"strconv" "strconv"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/utils/str" "github.com/navidrome/navidrome/utils/str"
) )
@@ -77,7 +77,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
// Persistent IDs // Persistent IDs
mf.PID = md.trackPID(mf) mf.PID = md.trackPID(mf)
mf.AlbumID = md.albumID(mf) mf.AlbumID = md.albumID(mf, conf.Server.PID.Album)
// BFR These IDs will go away once the UI handle multiple participants. // BFR These IDs will go away once the UI handle multiple participants.
// BFR For Legacy Subsonic compatibility, we will set them in the API handlers // BFR For Legacy Subsonic compatibility, we will set them in the API handlers
@@ -104,8 +104,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
} }
func (md Metadata) AlbumID(mf model.MediaFile, pidConf string) string { func (md Metadata) AlbumID(mf model.MediaFile, pidConf string) string {
getPID := createGetPID(id.NewHash) return md.albumID(mf, pidConf)
return getPID(mf, md, pidConf)
} }
func (md Metadata) mapGain(rg, r128 model.TagName) *float64 { func (md Metadata) mapGain(rg, r128 model.TagName) *float64 {

View File

@@ -2,6 +2,7 @@ package metadata
import ( import (
"cmp" "cmp"
"fmt"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -21,13 +22,15 @@ type hashFunc = func(...string) string
// Attributes can be either tags or some processed values like folder, albumid, albumartistid, etc. // Attributes can be either tags or some processed values like folder, albumid, albumartistid, etc.
// For each field, it gets all its attributes values and concatenates them, then hashes the result. // For each field, it gets all its attributes values and concatenates them, then hashes the result.
// If a field is empty, it is skipped and the function looks for the next field. // If a field is empty, it is skipped and the function looks for the next field.
func createGetPID(hash hashFunc) func(mf model.MediaFile, md Metadata, spec string) string { type getPIDFunc = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string
var getPID func(mf model.MediaFile, md Metadata, spec string) string
getAttr := func(mf model.MediaFile, md Metadata, attr string) string { func createGetPID(hash hashFunc) getPIDFunc {
var getPID getPIDFunc
getAttr := func(mf model.MediaFile, md Metadata, attr string, prependLibId bool) string {
attr = strings.TrimSpace(strings.ToLower(attr)) attr = strings.TrimSpace(strings.ToLower(attr))
switch attr { switch attr {
case "albumid": case "albumid":
return getPID(mf, md, conf.Server.PID.Album) return getPID(mf, md, conf.Server.PID.Album, prependLibId)
case "folder": case "folder":
return filepath.Dir(mf.Path) return filepath.Dir(mf.Path)
case "albumartistid": case "albumartistid":
@@ -39,14 +42,14 @@ func createGetPID(hash hashFunc) func(mf model.MediaFile, md Metadata, spec stri
} }
return md.String(model.TagName(attr)) return md.String(model.TagName(attr))
} }
getPID = func(mf model.MediaFile, md Metadata, spec string) string { getPID = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
pid := "" pid := ""
fields := strings.Split(spec, "|") fields := strings.Split(spec, "|")
for _, field := range fields { for _, field := range fields {
attributes := strings.Split(field, ",") attributes := strings.Split(field, ",")
hasValue := false hasValue := false
values := slice.Map(attributes, func(attr string) string { values := slice.Map(attributes, func(attr string) string {
v := getAttr(mf, md, attr) v := getAttr(mf, md, attr, prependLibId)
if v != "" { if v != "" {
hasValue = true hasValue = true
} }
@@ -57,32 +60,35 @@ func createGetPID(hash hashFunc) func(mf model.MediaFile, md Metadata, spec stri
break break
} }
} }
if prependLibId {
pid = fmt.Sprintf("%d\\%s", mf.LibraryID, pid)
}
return hash(pid) return hash(pid)
} }
return func(mf model.MediaFile, md Metadata, spec string) string { return func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
switch spec { switch spec {
case "track_legacy": case "track_legacy":
return legacyTrackID(mf) return legacyTrackID(mf, prependLibId)
case "album_legacy": case "album_legacy":
return legacyAlbumID(md) return legacyAlbumID(mf, md, prependLibId)
} }
return getPID(mf, md, spec) return getPID(mf, md, spec, prependLibId)
} }
} }
func (md Metadata) trackPID(mf model.MediaFile) string { func (md Metadata) trackPID(mf model.MediaFile) string {
return createGetPID(id.NewHash)(mf, md, conf.Server.PID.Track) return createGetPID(id.NewHash)(mf, md, conf.Server.PID.Track, true)
} }
func (md Metadata) albumID(mf model.MediaFile) string { func (md Metadata) albumID(mf model.MediaFile, pidConf string) string {
return createGetPID(id.NewHash)(mf, md, conf.Server.PID.Album) return createGetPID(id.NewHash)(mf, md, pidConf, true)
} }
// BFR Must be configurable? // BFR Must be configurable?
func (md Metadata) artistID(name string) string { func (md Metadata) artistID(name string) string {
mf := model.MediaFile{AlbumArtist: name} mf := model.MediaFile{AlbumArtist: name}
return createGetPID(id.NewHash)(mf, md, "albumartistid") return createGetPID(id.NewHash)(mf, md, "albumartistid", false)
} }
func (md Metadata) mapTrackTitle() string { func (md Metadata) mapTrackTitle() string {

View File

@@ -15,7 +15,7 @@ var _ = Describe("getPID", func() {
md Metadata md Metadata
mf model.MediaFile mf model.MediaFile
sum hashFunc sum hashFunc
getPID func(mf model.MediaFile, md Metadata, spec string) string getPID getPIDFunc
) )
BeforeEach(func() { BeforeEach(func() {
@@ -28,7 +28,7 @@ var _ = Describe("getPID", func() {
When("no attributes were present", func() { When("no attributes were present", func() {
It("should return empty pid", func() { It("should return empty pid", func() {
md.tags = map[model.TagName][]string{} md.tags = map[model.TagName][]string{}
pid := getPID(mf, md, spec) pid := getPID(mf, md, spec, false)
Expect(pid).To(Equal("()")) Expect(pid).To(Equal("()"))
}) })
}) })
@@ -40,7 +40,7 @@ var _ = Describe("getPID", func() {
"discnumber": {"1"}, "discnumber": {"1"},
"tracknumber": {"1"}, "tracknumber": {"1"},
} }
Expect(getPID(mf, md, spec)).To(Equal("(mbtrackid)")) Expect(getPID(mf, md, spec, false)).To(Equal("(mbtrackid)"))
}) })
}) })
When("only first field is present", func() { When("only first field is present", func() {
@@ -48,7 +48,7 @@ var _ = Describe("getPID", func() {
md.tags = map[model.TagName][]string{ md.tags = map[model.TagName][]string{
"musicbrainz_trackid": {"mbtrackid"}, "musicbrainz_trackid": {"mbtrackid"},
} }
Expect(getPID(mf, md, spec)).To(Equal("(mbtrackid)")) Expect(getPID(mf, md, spec, false)).To(Equal("(mbtrackid)"))
}) })
}) })
When("first is empty, but second field is present", func() { When("first is empty, but second field is present", func() {
@@ -57,7 +57,7 @@ var _ = Describe("getPID", func() {
"album": {"album name"}, "album": {"album name"},
"discnumber": {"1"}, "discnumber": {"1"},
} }
Expect(getPID(mf, md, spec)).To(Equal("(album name\\1\\)")) Expect(getPID(mf, md, spec, false)).To(Equal("(album name\\1\\)"))
}) })
}) })
}) })
@@ -73,7 +73,7 @@ var _ = Describe("getPID", func() {
md.tags = map[model.TagName][]string{"title": {"title"}} md.tags = map[model.TagName][]string{"title": {"title"}}
md.filePath = "/path/to/file.mp3" md.filePath = "/path/to/file.mp3"
mf.Title = "Title" mf.Title = "Title"
Expect(getPID(mf, md, spec)).To(Equal("(Title)")) Expect(getPID(mf, md, spec, false)).To(Equal("(Title)"))
}) })
}) })
When("field is folder", func() { When("field is folder", func() {
@@ -81,7 +81,7 @@ var _ = Describe("getPID", func() {
spec := "folder|title" spec := "folder|title"
md.tags = map[model.TagName][]string{"title": {"title"}} md.tags = map[model.TagName][]string{"title": {"title"}}
mf.Path = "/path/to/file.mp3" mf.Path = "/path/to/file.mp3"
Expect(getPID(mf, md, spec)).To(Equal("(/path/to)")) Expect(getPID(mf, md, spec, false)).To(Equal("(/path/to)"))
}) })
}) })
When("field is albumid", func() { When("field is albumid", func() {
@@ -94,7 +94,7 @@ var _ = Describe("getPID", func() {
"releasedate": {"2021-01-01"}, "releasedate": {"2021-01-01"},
} }
mf.AlbumArtist = "Album Artist" mf.AlbumArtist = "Album Artist"
Expect(getPID(mf, md, spec)).To(Equal("(((album artist)\\album name\\version\\2021-01-01))")) Expect(getPID(mf, md, spec, false)).To(Equal("(((album artist)\\album name\\version\\2021-01-01))"))
}) })
}) })
When("field is albumartistid", func() { When("field is albumartistid", func() {
@@ -104,14 +104,14 @@ var _ = Describe("getPID", func() {
"albumartist": {"Album Artist"}, "albumartist": {"Album Artist"},
} }
mf.AlbumArtist = "Album Artist" mf.AlbumArtist = "Album Artist"
Expect(getPID(mf, md, spec)).To(Equal("((album artist))")) Expect(getPID(mf, md, spec, false)).To(Equal("((album artist))"))
}) })
}) })
When("field is album", func() { When("field is album", func() {
It("should return the pid", func() { It("should return the pid", func() {
spec := "album|title" spec := "album|title"
md.tags = map[model.TagName][]string{"album": {"Album Name"}} md.tags = map[model.TagName][]string{"album": {"Album Name"}}
Expect(getPID(mf, md, spec)).To(Equal("(album name)")) Expect(getPID(mf, md, spec, false)).To(Equal("(album name)"))
}) })
}) })
}) })
@@ -123,7 +123,7 @@ var _ = Describe("getPID", func() {
md.tags = map[model.TagName][]string{ md.tags = map[model.TagName][]string{
"album": {"album name"}, "album": {"album name"},
} }
Expect(getPID(mf, md, spec)).To(Equal("(album name)")) Expect(getPID(mf, md, spec, false)).To(Equal("(album name)"))
}) })
}) })
When("the spec has spaces", func() { When("the spec has spaces", func() {
@@ -133,7 +133,7 @@ var _ = Describe("getPID", func() {
"albumartist": {"Album Artist"}, "albumartist": {"Album Artist"},
"album": {"album name"}, "album": {"album name"},
} }
Expect(getPID(mf, md, spec)).To(Equal("(Album Artist\\album name)")) Expect(getPID(mf, md, spec, false)).To(Equal("(Album Artist\\album name)"))
}) })
}) })
When("the spec has mixed case fields", func() { When("the spec has mixed case fields", func() {
@@ -143,7 +143,129 @@ var _ = Describe("getPID", func() {
"albumartist": {"Album Artist"}, "albumartist": {"Album Artist"},
"album": {"album name"}, "album": {"album name"},
} }
Expect(getPID(mf, md, spec)).To(Equal("(Album Artist\\album name)")) Expect(getPID(mf, md, spec, false)).To(Equal("(Album Artist\\album name)"))
})
})
})
Context("prependLibId functionality", func() {
BeforeEach(func() {
mf.LibraryID = 42
})
When("prependLibId is true", func() {
It("should prepend library ID to the hash input", func() {
spec := "album"
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
pid := getPID(mf, md, spec, true)
// The hash function should receive "42\test album" as input
Expect(pid).To(Equal("(42\\test album)"))
})
})
When("prependLibId is false", func() {
It("should not prepend library ID to the hash input", func() {
spec := "album"
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
pid := getPID(mf, md, spec, false)
// The hash function should receive "test album" as input
Expect(pid).To(Equal("(test album)"))
})
})
When("prependLibId is true with complex spec", func() {
It("should prepend library ID to the final hash input", func() {
spec := "musicbrainz_trackid|album,tracknumber"
md.tags = map[model.TagName][]string{
"album": {"Test Album"},
"tracknumber": {"1"},
}
pid := getPID(mf, md, spec, true)
// Should use the fallback field and prepend library ID
Expect(pid).To(Equal("(42\\test album\\1)"))
})
})
When("prependLibId is true with nested albumid", func() {
It("should handle nested albumid calls correctly", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.PID.Album = "album"
spec := "albumid"
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
mf.AlbumArtist = "Test Artist"
pid := getPID(mf, md, spec, true)
// The albumid call should also use prependLibId=true
Expect(pid).To(Equal("(42\\(42\\test album))"))
})
})
})
Context("legacy specs", func() {
Context("track_legacy", func() {
When("library ID is default (1)", func() {
It("should not prepend library ID even when prependLibId is true", func() {
mf.Path = "/path/to/track.mp3"
mf.LibraryID = 1 // Default library ID
// With default library, both should be the same
pidTrue := getPID(mf, md, "track_legacy", true)
pidFalse := getPID(mf, md, "track_legacy", false)
Expect(pidTrue).To(Equal(pidFalse))
Expect(pidTrue).NotTo(BeEmpty())
})
})
When("library ID is non-default", func() {
It("should prepend library ID when prependLibId is true", func() {
mf.Path = "/path/to/track.mp3"
mf.LibraryID = 2 // Non-default library ID
pidTrue := getPID(mf, md, "track_legacy", true)
pidFalse := getPID(mf, md, "track_legacy", false)
Expect(pidTrue).NotTo(Equal(pidFalse))
Expect(pidTrue).NotTo(BeEmpty())
Expect(pidFalse).NotTo(BeEmpty())
})
})
When("library ID is non-default but prependLibId is false", func() {
It("should not prepend library ID", func() {
mf.Path = "/path/to/track.mp3"
mf.LibraryID = 3
mf2 := mf
mf2.LibraryID = 1 // Default library
pidNonDefault := getPID(mf, md, "track_legacy", false)
pidDefault := getPID(mf2, md, "track_legacy", false)
// Should be the same since prependLibId=false
Expect(pidNonDefault).To(Equal(pidDefault))
})
})
})
Context("album_legacy", func() {
When("library ID is default (1)", func() {
It("should not prepend library ID even when prependLibId is true", func() {
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
mf.LibraryID = 1 // Default library ID
pidTrue := getPID(mf, md, "album_legacy", true)
pidFalse := getPID(mf, md, "album_legacy", false)
Expect(pidTrue).To(Equal(pidFalse))
Expect(pidTrue).NotTo(BeEmpty())
})
})
When("library ID is non-default", func() {
It("should prepend library ID when prependLibId is true", func() {
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
mf.LibraryID = 2 // Non-default library ID
pidTrue := getPID(mf, md, "album_legacy", true)
pidFalse := getPID(mf, md, "album_legacy", false)
Expect(pidTrue).NotTo(Equal(pidFalse))
Expect(pidTrue).NotTo(BeEmpty())
Expect(pidFalse).NotTo(BeEmpty())
})
})
When("library ID is non-default but prependLibId is false", func() {
It("should not prepend library ID", func() {
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
mf.LibraryID = 3
mf2 := mf
mf2.LibraryID = 1 // Default library
pidNonDefault := getPID(mf, md, "album_legacy", false)
pidDefault := getPID(mf2, md, "album_legacy", false)
// Should be the same since prependLibId=false
Expect(pidNonDefault).To(Equal(pidDefault))
})
}) })
}) })
}) })

View File

@@ -40,6 +40,21 @@ func (pls Playlist) MediaFiles() MediaFiles {
return pls.Tracks.MediaFiles() return pls.Tracks.MediaFiles()
} }
func (pls *Playlist) refreshStats() {
pls.SongCount = len(pls.Tracks)
pls.Duration = 0
pls.Size = 0
for _, t := range pls.Tracks {
pls.Duration += t.MediaFile.Duration
pls.Size += t.MediaFile.Size
}
}
func (pls *Playlist) SetTracks(tracks PlaylistTracks) {
pls.Tracks = tracks
pls.refreshStats()
}
func (pls *Playlist) RemoveTracks(idxToRemove []int) { func (pls *Playlist) RemoveTracks(idxToRemove []int) {
var newTracks PlaylistTracks var newTracks PlaylistTracks
for i, t := range pls.Tracks { for i, t := range pls.Tracks {
@@ -49,6 +64,7 @@ func (pls *Playlist) RemoveTracks(idxToRemove []int) {
newTracks = append(newTracks, t) newTracks = append(newTracks, t)
} }
pls.Tracks = newTracks pls.Tracks = newTracks
pls.refreshStats()
} }
// ToM3U8 exports the playlist to the Extended M3U8 format // ToM3U8 exports the playlist to the Extended M3U8 format
@@ -56,7 +72,7 @@ func (pls *Playlist) ToM3U8() string {
return pls.MediaFiles().ToM3U8(pls.Name, true) return pls.MediaFiles().ToM3U8(pls.Name, true)
} }
func (pls *Playlist) AddTracks(mediaFileIds []string) { func (pls *Playlist) AddMediaFilesByID(mediaFileIds []string) {
pos := len(pls.Tracks) pos := len(pls.Tracks)
for _, mfId := range mediaFileIds { for _, mfId := range mediaFileIds {
pos++ pos++
@@ -68,6 +84,7 @@ func (pls *Playlist) AddTracks(mediaFileIds []string) {
} }
pls.Tracks = append(pls.Tracks, t) pls.Tracks = append(pls.Tracks, t)
} }
pls.refreshStats()
} }
func (pls *Playlist) AddMediaFiles(mfs MediaFiles) { func (pls *Playlist) AddMediaFiles(mfs MediaFiles) {
@@ -82,6 +99,7 @@ func (pls *Playlist) AddMediaFiles(mfs MediaFiles) {
} }
pls.Tracks = append(pls.Tracks, t) pls.Tracks = append(pls.Tracks, t)
} }
pls.refreshStats()
} }
func (pls Playlist) CoverArtID() ArtworkID { func (pls Playlist) CoverArtID() ArtworkID {

View File

@@ -1,5 +1,5 @@
package model package model
type SearchableRepository[T any] interface { type SearchableRepository[T any] interface {
Search(q string, offset, size int, includeMissing bool) (T, error) Search(q string, offset, size int, includeMissing bool, options ...QueryOptions) (T, error)
} }

View File

@@ -153,7 +153,7 @@ func (t Tags) Add(name TagName, v string) {
} }
type TagRepository interface { type TagRepository interface {
Add(...Tag) error Add(libraryID int, tags ...Tag) error
UpdateCounts() error UpdateCounts() error
} }

View File

@@ -1,6 +1,8 @@
package model package model
import "time" import (
"time"
)
type User struct { type User struct {
ID string `structs:"id" json:"id"` ID string `structs:"id" json:"id"`
@@ -13,6 +15,9 @@ type User struct {
CreatedAt time.Time `structs:"created_at" json:"createdAt"` CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
// Library associations (many-to-many relationship)
Libraries Libraries `structs:"-" json:"libraries,omitempty"`
// This is only available on the backend, and it is never sent over the wire // This is only available on the backend, and it is never sent over the wire
Password string `structs:"-" json:"-"` Password string `structs:"-" json:"-"`
// This is used to set or change a password when calling Put. If it is empty, the password is not changed. // This is used to set or change a password when calling Put. If it is empty, the password is not changed.
@@ -22,6 +27,18 @@ type User struct {
CurrentPassword string `structs:"current_password,omitempty" json:"currentPassword,omitempty"` CurrentPassword string `structs:"current_password,omitempty" json:"currentPassword,omitempty"`
} }
func (u User) HasLibraryAccess(libraryID int) bool {
if u.IsAdmin {
return true // Admin users have access to all libraries
}
for _, lib := range u.Libraries {
if lib.ID == libraryID {
return true
}
}
return false
}
type Users []User type Users []User
type UserRepository interface { type UserRepository interface {
@@ -35,4 +52,8 @@ type UserRepository interface {
FindByUsername(username string) (*User, error) FindByUsername(username string) (*User, error)
// FindByUsernameWithPassword is the same as above, but also returns the decrypted password // FindByUsernameWithPassword is the same as above, but also returns the decrypted password
FindByUsernameWithPassword(username string) (*User, error) FindByUsernameWithPassword(username string) (*User, error)
// Library association methods
GetUserLibraries(userID string) (Libraries, error)
SetUserLibraries(userID string, libraryIDs []int) error
} }

83
model/user_test.go Normal file
View File

@@ -0,0 +1,83 @@
package model_test
import (
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("User", func() {
var user model.User
var libraries model.Libraries
BeforeEach(func() {
libraries = model.Libraries{
{ID: 1, Name: "Rock Library", Path: "/music/rock"},
{ID: 2, Name: "Jazz Library", Path: "/music/jazz"},
{ID: 3, Name: "Classical Library", Path: "/music/classical"},
}
user = model.User{
ID: "user1",
UserName: "testuser",
Name: "Test User",
Email: "test@example.com",
IsAdmin: false,
Libraries: libraries,
}
})
Describe("HasLibraryAccess", func() {
Context("when user is admin", func() {
BeforeEach(func() {
user.IsAdmin = true
})
It("returns true for any library ID", func() {
Expect(user.HasLibraryAccess(1)).To(BeTrue())
Expect(user.HasLibraryAccess(99)).To(BeTrue())
Expect(user.HasLibraryAccess(-1)).To(BeTrue())
})
It("returns true even when user has no libraries assigned", func() {
user.Libraries = nil
Expect(user.HasLibraryAccess(1)).To(BeTrue())
})
})
Context("when user is not admin", func() {
BeforeEach(func() {
user.IsAdmin = false
})
It("returns true for libraries the user has access to", func() {
Expect(user.HasLibraryAccess(1)).To(BeTrue())
Expect(user.HasLibraryAccess(2)).To(BeTrue())
Expect(user.HasLibraryAccess(3)).To(BeTrue())
})
It("returns false for libraries the user does not have access to", func() {
Expect(user.HasLibraryAccess(4)).To(BeFalse())
Expect(user.HasLibraryAccess(99)).To(BeFalse())
Expect(user.HasLibraryAccess(-1)).To(BeFalse())
Expect(user.HasLibraryAccess(0)).To(BeFalse())
})
It("returns false when user has no libraries assigned", func() {
user.Libraries = nil
Expect(user.HasLibraryAccess(1)).To(BeFalse())
})
It("handles duplicate library IDs correctly", func() {
user.Libraries = model.Libraries{
{ID: 1, Name: "Library 1", Path: "/music1"},
{ID: 1, Name: "Library 1 Duplicate", Path: "/music1-dup"},
{ID: 2, Name: "Library 2", Path: "/music2"},
}
Expect(user.HasLibraryAccess(1)).To(BeTrue())
Expect(user.HasLibraryAccess(2)).To(BeTrue())
Expect(user.HasLibraryAccess(3)).To(BeFalse())
})
})
})
})

View File

@@ -123,6 +123,7 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc {
"missing": booleanFilter, "missing": booleanFilter,
"genre_id": tagIDFilter, "genre_id": tagIDFilter,
"role_total_id": allRolesFilter, "role_total_id": allRolesFilter,
"library_id": libraryIdFilter,
} }
// Add all album tags as filters // Add all album tags as filters
for tag := range model.AlbumLevelTags() { for tag := range model.AlbumLevelTags() {
@@ -184,9 +185,10 @@ func allRolesFilter(_ string, value interface{}) Sqlizer {
} }
func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) { func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) {
sql := r.newSelect() query := r.newSelect()
sql = r.withAnnotation(sql, "album.id") query = r.withAnnotation(query, "album.id")
return r.count(sql, options...) query = r.applyLibraryFilter(query)
return r.count(query, options...)
} }
func (r *albumRepository) Exists(id string) (bool, error) { func (r *albumRepository) Exists(id string) (bool, error) {
@@ -216,8 +218,10 @@ func (r *albumRepository) UpdateExternalInfo(al *model.Album) error {
} }
func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder { func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder {
sql := r.newSelect(options...).Columns("album.*") sql := r.newSelect(options...).Columns("album.*", "library.path as library_path", "library.name as library_name").
return r.withAnnotation(sql, "album.id") LeftJoin("library on album.library_id = library.id")
sql = r.withAnnotation(sql, "album.id")
return r.applyLibraryFilter(sql)
} }
func (r *albumRepository) Get(id string) (*model.Album, error) { func (r *albumRepository) Get(id string) (*model.Album, error) {
@@ -291,7 +295,6 @@ func (r *albumRepository) TouchByMissingFolder() (int64, error) {
// It does not need to load participants, as they are not used by the scanner. // It does not need to load participants, as they are not used by the scanner.
func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error) { func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error) {
query := r.selectAlbum(). query := r.selectAlbum().
Join("library on library.id = album.library_id").
Where(And{ Where(And{
Eq{"library.id": libID}, Eq{"library.id": libID},
ConcatExpr("album.imported_at > library.last_scan_at"), ConcatExpr("album.imported_at > library.last_scan_at"),
@@ -346,15 +349,15 @@ func (r *albumRepository) purgeEmpty() error {
return nil return nil
} }
func (r *albumRepository) Search(q string, offset int, size int, includeMissing bool) (model.Albums, error) { func (r *albumRepository) Search(q string, offset int, size int, includeMissing bool, options ...model.QueryOptions) (model.Albums, error) {
var res dbAlbums var res dbAlbums
if uuid.Validate(q) == nil { if uuid.Validate(q) == nil {
err := r.searchByMBID(r.selectAlbum(), q, []string{"mbz_album_id", "mbz_release_group_id"}, includeMissing, &res) err := r.searchByMBID(r.selectAlbum(options...), q, []string{"mbz_album_id", "mbz_release_group_id"}, includeMissing, &res)
if err != nil { if err != nil {
return nil, fmt.Errorf("searching album by MBID %q: %w", q, err) return nil, fmt.Errorf("searching album by MBID %q: %w", q, err)
} }
} else { } else {
err := r.doSearch(r.selectAlbum(), q, offset, size, includeMissing, &res, "name") err := r.doSearch(r.selectAlbum(options...), q, offset, size, includeMissing, &res, "name")
if err != nil { if err != nil {
return nil, fmt.Errorf("searching album by query %q: %w", q, err) return nil, fmt.Errorf("searching album by query %q: %w", q, err)
} }

View File

@@ -27,9 +27,9 @@ type artistRepository struct {
} }
type dbArtist struct { type dbArtist struct {
*model.Artist `structs:",flatten"` *model.Artist `structs:",flatten"`
SimilarArtists string `structs:"-" json:"-"` SimilarArtists string `structs:"-" json:"-"`
Stats string `structs:"-" json:"-"` LibraryStatsJSON string `structs:"-" json:"-"`
} }
type dbSimilarArtist struct { type dbSimilarArtist struct {
@@ -38,27 +38,45 @@ type dbSimilarArtist struct {
} }
func (a *dbArtist) PostScan() error { func (a *dbArtist) PostScan() error {
var stats map[string]map[string]int64
if err := json.Unmarshal([]byte(a.Stats), &stats); err != nil {
return fmt.Errorf("parsing artist stats from db: %w", err)
}
a.Artist.Stats = make(map[model.Role]model.ArtistStats) a.Artist.Stats = make(map[model.Role]model.ArtistStats)
for key, c := range stats {
if key == "total" { if a.LibraryStatsJSON != "" {
a.Artist.Size = c["s"] var rawLibStats map[string]map[string]map[string]int64
a.Artist.SongCount = int(c["m"]) if err := json.Unmarshal([]byte(a.LibraryStatsJSON), &rawLibStats); err != nil {
a.Artist.AlbumCount = int(c["a"]) return fmt.Errorf("parsing artist stats from db: %w", err)
} }
role := model.RoleFromString(key)
if role == model.RoleInvalid { for _, stats := range rawLibStats {
continue // Sum all libraries roles stats
} for key, stat := range stats {
a.Artist.Stats[role] = model.ArtistStats{ // Aggregate stats into the main Artist.Stats map
SongCount: int(c["m"]), artistStats := model.ArtistStats{
AlbumCount: int(c["a"]), SongCount: int(stat["m"]),
Size: c["s"], AlbumCount: int(stat["a"]),
Size: stat["s"],
}
// Store total stats into the main attributes
if key == "total" {
a.Artist.Size += artistStats.Size
a.Artist.SongCount += artistStats.SongCount
a.Artist.AlbumCount += artistStats.AlbumCount
}
role := model.RoleFromString(key)
if role == model.RoleInvalid {
continue
}
current := a.Artist.Stats[role]
current.Size += artistStats.Size
current.SongCount += artistStats.SongCount
current.AlbumCount += artistStats.AlbumCount
a.Artist.Stats[role] = current
}
} }
} }
a.Artist.SimilarArtists = nil a.Artist.SimilarArtists = nil
if a.SimilarArtists == "" { if a.SimilarArtists == "" {
return nil return nil
@@ -113,11 +131,12 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups) r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups)
r.tableName = "artist" // To be used by the idFilter below r.tableName = "artist" // To be used by the idFilter below
r.registerModel(&model.Artist{}, map[string]filterFunc{ r.registerModel(&model.Artist{}, map[string]filterFunc{
"id": idFilter(r.tableName), "id": idFilter(r.tableName),
"name": fullTextFilter(r.tableName, "mbz_artist_id"), "name": fullTextFilter(r.tableName, "mbz_artist_id"),
"starred": booleanFilter, "starred": booleanFilter,
"role": roleFilter, "role": roleFilter,
"missing": booleanFilter, "missing": booleanFilter,
"library_id": artistLibraryIdFilter,
}) })
r.setSortMappings(map[string]string{ r.setSortMappings(map[string]string{
"name": "order_artist_name", "name": "order_artist_name",
@@ -127,9 +146,9 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
"size": "stats->>'total'->>'s'", "size": "stats->>'total'->>'s'",
// Stats by credits that are currently available // Stats by credits that are currently available
"maincredit_song_count": "stats->>'maincredit'->>'m'", "maincredit_song_count": "sum(stats->>'maincredit'->>'m')",
"maincredit_album_count": "stats->>'maincredit'->>'a'", "maincredit_album_count": "sum(stats->>'maincredit'->>'a')",
"maincredit_size": "stats->>'maincredit'->>'a'", "maincredit_size": "sum(stats->>'maincredit'->>'s')",
}) })
return r return r
} }
@@ -137,26 +156,58 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
func roleFilter(_ string, role any) Sqlizer { func roleFilter(_ string, role any) Sqlizer {
if role, ok := role.(string); ok { if role, ok := role.(string); ok {
if _, ok := model.AllRoles[role]; ok { if _, ok := model.AllRoles[role]; ok {
return NotEq{fmt.Sprintf("stats ->> '$.%v'", role): nil} return Expr("EXISTS (SELECT 1 FROM library_artist WHERE library_artist.artist_id = artist.id AND JSON_EXTRACT(library_artist.stats, '$." + role + ".m') IS NOT NULL)")
} }
} }
return Eq{"1": 2} return Eq{"1": 2}
} }
func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder { // artistLibraryIdFilter filters artists based on library access through the library_artist table
query := r.newSelect(options...).Columns("artist.*") func artistLibraryIdFilter(_ string, value interface{}) Sqlizer {
query = r.withAnnotation(query, "artist.id") return Eq{"library_artist.library_id": value}
}
// applyLibraryFilterToArtistQuery applies library filtering to artist queries through the library_artist junction table
func (r *artistRepository) applyLibraryFilterToArtistQuery(query SelectBuilder) SelectBuilder {
user := loggedUser(r.ctx)
if user.ID == invalidUserId {
// No user context - return empty result set
return query.Where(Eq{"1": "0"})
}
// Apply library filtering by joining only with accessible libraries
query = query.LeftJoin("library_artist on library_artist.artist_id = artist.id").
Join("user_library on user_library.library_id = library_artist.library_id AND user_library.user_id = ?", user.ID)
return query return query
} }
func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder {
// Stats Format: {"1": {"albumartist": {"songCount": 10, "albumCount": 5, "size": 1024}, "artist": {...}}, "2": {...}}
query := r.newSelect(options...).Columns("artist.*",
"JSON_GROUP_OBJECT(library_artist.library_id, JSONB(library_artist.stats)) as library_stats_json")
query = r.applyLibraryFilterToArtistQuery(query)
query = query.GroupBy("artist.id")
return r.withAnnotation(query, "artist.id")
}
func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error) { func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error) {
query := r.newSelect() query := r.newSelect()
query = r.applyLibraryFilterToArtistQuery(query)
query = r.withAnnotation(query, "artist.id") query = r.withAnnotation(query, "artist.id")
return r.count(query, options...) return r.count(query, options...)
} }
// Exists checks if an artist with the given ID exists in the database and is accessible by the current user.
func (r *artistRepository) Exists(id string) (bool, error) { func (r *artistRepository) Exists(id string) (bool, error) {
return r.exists(Eq{"artist.id": id}) // Create a query using the same library filtering logic as selectArtist()
query := r.newSelect().Columns("count(distinct artist.id) as exist").Where(Eq{"artist.id": id})
query = r.applyLibraryFilterToArtistQuery(query)
var res struct{ Exist int64 }
err := r.queryOne(query, &res)
return res.Exist > 0, err
} }
func (r *artistRepository) Put(a *model.Artist, colsToUpdate ...string) error { func (r *artistRepository) Put(a *model.Artist, colsToUpdate ...string) error {
@@ -213,8 +264,15 @@ func (r *artistRepository) getIndexKey(a model.Artist) string {
return "#" return "#"
} }
// TODO Cache the index (recalculate when there are changes to the DB) // GetIndex returns a list of artists grouped by the first letter of their name, or by the index group if configured.
func (r *artistRepository) GetIndex(includeMissing bool, roles ...model.Role) (model.ArtistIndexes, error) { // It can filter by roles and libraries, and optionally include artists that are missing (i.e., have no albums).
// TODO Cache the index (recalculate at scan time)
func (r *artistRepository) GetIndex(includeMissing bool, libraryIds []int, roles ...model.Role) (model.ArtistIndexes, error) {
// Validate library IDs. If no library IDs are provided, return an empty index.
if len(libraryIds) == 0 {
return nil, nil
}
options := model.QueryOptions{Sort: "name"} options := model.QueryOptions{Sort: "name"}
if len(roles) > 0 { if len(roles) > 0 {
roleFilters := slice.Map(roles, func(r model.Role) Sqlizer { roleFilters := slice.Map(roles, func(r model.Role) Sqlizer {
@@ -229,10 +287,19 @@ func (r *artistRepository) GetIndex(includeMissing bool, roles ...model.Role) (m
options.Filters = And{options.Filters, Eq{"artist.missing": false}} options.Filters = And{options.Filters, Eq{"artist.missing": false}}
} }
} }
libFilter := artistLibraryIdFilter("library_id", libraryIds)
if options.Filters == nil {
options.Filters = libFilter
} else {
options.Filters = And{options.Filters, libFilter}
}
artists, err := r.GetAll(options) artists, err := r.GetAll(options)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var result model.ArtistIndexes var result model.ArtistIndexes
for k, v := range slice.Group(artists, r.getIndexKey) { for k, v := range slice.Group(artists, r.getIndexKey) {
result = append(result, model.ArtistIndex{ID: k, Artists: v}) result = append(result, model.ArtistIndex{ID: k, Artists: v})
@@ -299,6 +366,7 @@ on conflict (user_id, item_id, item_type) do update
// RefreshStats updates the stats field for artists whose associated media files were updated after the oldest recorded library scan time. // RefreshStats updates the stats field for artists whose associated media files were updated after the oldest recorded library scan time.
// When allArtists is true, it refreshes stats for all artists. It processes artists in batches to handle potentially large updates. // When allArtists is true, it refreshes stats for all artists. It processes artists in batches to handle potentially large updates.
// This method now calculates per-library statistics and stores them in the library_artist junction table.
func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
var allTouchedArtistIDs []string var allTouchedArtistIDs []string
if allArtists { if allArtists {
@@ -327,9 +395,11 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
} }
// Template for the batch update with placeholder markers that we'll replace // Template for the batch update with placeholder markers that we'll replace
// This now calculates per-library statistics and stores them in library_artist.stats
batchUpdateStatsSQL := ` batchUpdateStatsSQL := `
WITH artist_role_counters AS ( WITH artist_role_counters AS (
SELECT jt.atom AS artist_id, SELECT jt.atom AS artist_id,
mf.library_id,
substr( substr(
replace(jt.path, '$.', ''), replace(jt.path, '$.', ''),
1, 1,
@@ -344,10 +414,11 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
FROM media_file mf FROM media_file mf
JOIN json_tree(mf.participants) jt ON jt.key = 'id' AND jt.atom IS NOT NULL JOIN json_tree(mf.participants) jt ON jt.key = 'id' AND jt.atom IS NOT NULL
WHERE jt.atom IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders WHERE jt.atom IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
GROUP BY jt.atom, role GROUP BY jt.atom, mf.library_id, role
), ),
artist_total_counters AS ( artist_total_counters AS (
SELECT mfa.artist_id, SELECT mfa.artist_id,
mf.library_id,
'total' AS role, 'total' AS role,
count(DISTINCT mf.album_id) AS album_count, count(DISTINCT mf.album_id) AS album_count,
count(DISTINCT mf.id) AS count, count(DISTINCT mf.id) AS count,
@@ -355,40 +426,43 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
FROM media_file_artists mfa FROM media_file_artists mfa
JOIN media_file mf ON mfa.media_file_id = mf.id JOIN media_file mf ON mfa.media_file_id = mf.id
WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
GROUP BY mfa.artist_id GROUP BY mfa.artist_id, mf.library_id
), ),
artist_participant_counter AS ( artist_participant_counter AS (
SELECT mfa.artist_id, SELECT mfa.artist_id,
'maincredit' AS role, mf.library_id,
count(DISTINCT mf.album_id) AS album_count, 'maincredit' AS role,
count(DISTINCT mf.id) AS count, count(DISTINCT mf.album_id) AS album_count,
sum(mf.size) AS size count(DISTINCT mf.id) AS count,
sum(mf.size) AS size
FROM media_file_artists mfa FROM media_file_artists mfa
JOIN media_file mf ON mfa.media_file_id = mf.id JOIN media_file mf ON mfa.media_file_id = mf.id
WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
AND mfa.role IN ('albumartist', 'artist') AND mfa.role IN ('albumartist', 'artist')
GROUP BY mfa.artist_id GROUP BY mfa.artist_id, mf.library_id
), ),
combined_counters AS ( combined_counters AS (
SELECT artist_id, role, album_count, count, size FROM artist_role_counters SELECT artist_id, library_id, role, album_count, count, size FROM artist_role_counters
UNION UNION
SELECT artist_id, role, album_count, count, size FROM artist_total_counters SELECT artist_id, library_id, role, album_count, count, size FROM artist_total_counters
UNION UNION
SELECT artist_id, role, album_count, count, size FROM artist_participant_counter SELECT artist_id, library_id, role, album_count, count, size FROM artist_participant_counter
), ),
artist_counters AS ( library_artist_counters AS (
SELECT artist_id AS id, SELECT artist_id,
library_id,
json_group_object( json_group_object(
replace(role, '"', ''), replace(role, '"', ''),
json_object('a', album_count, 'm', count, 's', size) json_object('a', album_count, 'm', count, 's', size)
) AS counters ) AS counters
FROM combined_counters FROM combined_counters
GROUP BY artist_id GROUP BY artist_id, library_id
) )
UPDATE artist UPDATE library_artist
SET stats = coalesce((SELECT counters FROM artist_counters ac WHERE ac.id = artist.id), '{}'), SET stats = coalesce((SELECT counters FROM library_artist_counters lac
updated_at = datetime(current_timestamp, 'localtime') WHERE lac.artist_id = library_artist.artist_id
WHERE artist.id IN (ROLE_IDS_PLACEHOLDER) AND artist.id <> '';` // Will replace with actual placeholders AND lac.library_id = library_artist.library_id), '{}')
WHERE library_artist.artist_id IN (ROLE_IDS_PLACEHOLDER);` // Will replace with actual placeholders
var totalRowsAffected int64 = 0 var totalRowsAffected int64 = 0
const batchSize = 1000 const batchSize = 1000
@@ -433,15 +507,16 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
return totalRowsAffected, nil return totalRowsAffected, nil
} }
func (r *artistRepository) Search(q string, offset int, size int, includeMissing bool) (model.Artists, error) { func (r *artistRepository) Search(q string, offset int, size int, includeMissing bool, options ...model.QueryOptions) (model.Artists, error) {
var res dbArtists var res dbArtists
if uuid.Validate(q) == nil { if uuid.Validate(q) == nil {
err := r.searchByMBID(r.selectArtist(), q, []string{"mbz_artist_id"}, includeMissing, &res) err := r.searchByMBID(r.selectArtist(options...), q, []string{"mbz_artist_id"}, includeMissing, &res)
if err != nil { if err != nil {
return nil, fmt.Errorf("searching artist by MBID %q: %w", q, err) return nil, fmt.Errorf("searching artist by MBID %q: %w", q, err)
} }
} else { } else {
err := r.doSearch(r.selectArtist(), q, offset, size, includeMissing, &res, "json_extract(stats, '$.total.m') desc", "name") err := r.doSearch(r.selectArtist(options...), q, offset, size, includeMissing, &res,
"sum(json_extract(stats, '$.total.m')) desc", "name")
if err != nil { if err != nil {
return nil, fmt.Errorf("searching artist by query %q: %w", q, err) return nil, fmt.Errorf("searching artist by query %q: %w", q, err)
} }
@@ -464,9 +539,9 @@ func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, e
role = v role = v
} }
} }
r.sortMappings["song_count"] = "stats->>'" + role + "'->>'m'" r.sortMappings["song_count"] = "sum(stats->>'" + role + "'->>'m')"
r.sortMappings["album_count"] = "stats->>'" + role + "'->>'a'" r.sortMappings["album_count"] = "sum(stats->>'" + role + "'->>'a')"
r.sortMappings["size"] = "stats->>'" + role + "'->>'s'" r.sortMappings["size"] = "sum(stats->>'" + role + "'->>'s')"
return r.GetAll(r.parseRestOptions(r.ctx, options...)) return r.GetAll(r.parseRestOptions(r.ctx, options...))
} }

View File

File diff suppressed because it is too large Load Diff

View File

@@ -61,8 +61,9 @@ func newFolderRepository(ctx context.Context, db dbx.Builder) model.FolderReposi
} }
func (r folderRepository) selectFolder(options ...model.QueryOptions) SelectBuilder { func (r folderRepository) selectFolder(options ...model.QueryOptions) SelectBuilder {
return r.newSelect(options...).Columns("folder.*", "library.path as library_path"). sql := r.newSelect(options...).Columns("folder.*", "library.path as library_path").
Join("library on library.id = folder.library_id") Join("library on library.id = folder.library_id")
return r.applyLibraryFilter(sql)
} }
func (r folderRepository) Get(id string) (*model.Folder, error) { func (r folderRepository) Get(id string) (*model.Folder, error) {
@@ -85,8 +86,9 @@ func (r folderRepository) GetAll(opt ...model.QueryOptions) ([]model.Folder, err
} }
func (r folderRepository) CountAll(opt ...model.QueryOptions) (int64, error) { func (r folderRepository) CountAll(opt ...model.QueryOptions) (int64, error) {
sq := r.newSelect(opt...).Columns("count(*)") query := r.newSelect(opt...).Columns("count(*)")
return r.count(sq) query = r.applyLibraryFilter(query)
return r.count(query)
} }
func (r folderRepository) GetLastUpdates(lib model.Library) (map[string]model.FolderUpdateInfo, error) { func (r folderRepository) GetLastUpdates(lib model.Library) (map[string]model.FolderUpdateInfo, error) {

View File

@@ -10,31 +10,18 @@ import (
) )
type genreRepository struct { type genreRepository struct {
sqlRepository *baseTagRepository
} }
func NewGenreRepository(ctx context.Context, db dbx.Builder) model.GenreRepository { func NewGenreRepository(ctx context.Context, db dbx.Builder) model.GenreRepository {
r := &genreRepository{} genreFilter := model.TagGenre
r.ctx = ctx return &genreRepository{
r.db = db baseTagRepository: newBaseTagRepository(ctx, db, &genreFilter),
r.registerModel(&model.Tag{}, map[string]filterFunc{ }
"name": containsFilter("tag_value"),
})
r.setSortMappings(map[string]string{
"name": "tag_name",
})
return r
} }
func (r *genreRepository) selectGenre(opt ...model.QueryOptions) SelectBuilder { func (r *genreRepository) selectGenre(opt ...model.QueryOptions) SelectBuilder {
return r.newSelect(opt...). return r.newSelect(opt...)
Columns(
"id",
"tag_value as name",
"album_count",
"media_file_count as song_count",
).
Where(Eq{"tag.tag_name": model.TagGenre})
} }
func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error) { func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error) {
@@ -44,12 +31,10 @@ func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error
return res, err return res, err
} }
func (r *genreRepository) Count(options ...rest.QueryOptions) (int64, error) { // Override ResourceRepository methods to return Genre objects instead of Tag objects
return r.count(r.selectGenre(), r.parseRestOptions(r.ctx, options...))
}
func (r *genreRepository) Read(id string) (interface{}, error) { func (r *genreRepository) Read(id string) (interface{}, error) {
sel := r.selectGenre().Columns("*").Where(Eq{"id": id}) sel := r.selectGenre().Where(Eq{"tag.id": id})
var res model.Genre var res model.Genre
err := r.queryOne(sel, &res) err := r.queryOne(sel, &res)
return &res, err return &res, err
@@ -59,10 +44,6 @@ func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (interface{}, er
return r.GetAll(r.parseRestOptions(r.ctx, options...)) return r.GetAll(r.parseRestOptions(r.ctx, options...))
} }
func (r *genreRepository) EntityName() string {
return r.tableName
}
func (r *genreRepository) NewInstance() interface{} { func (r *genreRepository) NewInstance() interface{} {
return &model.Genre{} return &model.Genre{}
} }

View File

@@ -0,0 +1,256 @@
package persistence
import (
"context"
"slices"
"strings"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("GenreRepository", func() {
var repo model.GenreRepository
var restRepo model.ResourceRepository
var tagRepo model.TagRepository
var ctx context.Context
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true})
genreRepo := NewGenreRepository(ctx, GetDBXBuilder())
repo = genreRepo
restRepo = genreRepo.(model.ResourceRepository)
tagRepo = NewTagRepository(ctx, GetDBXBuilder())
// Clear any existing tags to ensure test isolation
db := GetDBXBuilder()
_, err := db.NewQuery("DELETE FROM tag").Execute()
Expect(err).ToNot(HaveOccurred())
// Ensure library 1 exists and user has access to it
_, err = db.NewQuery("INSERT OR IGNORE INTO library (id, name, path, default_new_users) VALUES (1, 'Test Library', '/test', true)").Execute()
Expect(err).ToNot(HaveOccurred())
_, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ('userid', 1)").Execute()
Expect(err).ToNot(HaveOccurred())
// Add comprehensive test data that covers all test scenarios
newTag := func(name, value string) model.Tag {
return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value}
}
err = tagRepo.Add(1,
newTag("genre", "rock"),
newTag("genre", "pop"),
newTag("genre", "jazz"),
newTag("genre", "electronic"),
newTag("genre", "classical"),
newTag("genre", "ambient"),
newTag("genre", "techno"),
newTag("genre", "house"),
newTag("genre", "trance"),
newTag("genre", "Alternative Rock"),
newTag("genre", "Blues"),
newTag("genre", "Country"),
// These should not be counted as genres
newTag("mood", "happy"),
newTag("mood", "ambient"),
)
Expect(err).ToNot(HaveOccurred())
})
Describe("GetAll", func() {
It("should return all genres", func() {
genres, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
Expect(genres).To(HaveLen(12))
// Verify that all returned items are genres (TagName = "genre")
genreNames := make([]string, len(genres))
for i, genre := range genres {
genreNames[i] = genre.Name
}
Expect(genreNames).To(ContainElement("rock"))
Expect(genreNames).To(ContainElement("pop"))
Expect(genreNames).To(ContainElement("jazz"))
// Should not contain mood tags
Expect(genreNames).ToNot(ContainElement("happy"))
})
It("should support query options", func() {
// Test with limiting results
genres, err := repo.GetAll(model.QueryOptions{Max: 1})
Expect(err).ToNot(HaveOccurred())
Expect(genres).To(HaveLen(1))
})
It("should handle empty results gracefully", func() {
// Clear all genre tags
_, err := GetDBXBuilder().NewQuery("DELETE FROM tag WHERE tag_name = 'genre'").Execute()
Expect(err).ToNot(HaveOccurred())
genres, err := repo.GetAll()
Expect(err).ToNot(HaveOccurred())
Expect(genres).To(BeEmpty())
})
Describe("filtering and sorting", func() {
It("should filter by name using like match", func() {
// Test filtering by partial name match using the "name" filter which maps to containsFilter("tag_value")
options := model.QueryOptions{
Filters: squirrel.Like{"tag_value": "%rock%"}, // Direct field access
}
genres, err := repo.GetAll(options)
Expect(err).ToNot(HaveOccurred())
Expect(genres).To(HaveLen(2)) // Should match "rock" and "Alternative Rock"
// Verify all returned genres contain "rock" in their name
for _, genre := range genres {
Expect(strings.ToLower(genre.Name)).To(ContainSubstring("rock"))
}
})
It("should sort by name in ascending order", func() {
// Test sorting by name with the fixed mapping
options := model.QueryOptions{
Filters: squirrel.Like{"tag_value": "%e%"}, // Should match genres containing "e"
Sort: "name",
}
genres, err := repo.GetAll(options)
Expect(err).ToNot(HaveOccurred())
Expect(genres).To(HaveLen(7))
Expect(slices.IsSortedFunc(genres, func(a, b model.Genre) int {
return strings.Compare(b.Name, a.Name) // Inverted to check descending order
}))
})
It("should sort by name in descending order", func() {
// Test sorting by name in descending order
options := model.QueryOptions{
Filters: squirrel.Like{"tag_value": "%e%"}, // Should match genres containing "e"
Sort: "name",
Order: "desc",
}
genres, err := repo.GetAll(options)
Expect(err).ToNot(HaveOccurred())
Expect(genres).To(HaveLen(7))
Expect(slices.IsSortedFunc(genres, func(a, b model.Genre) int {
return strings.Compare(a.Name, b.Name)
}))
})
})
})
Describe("Count", func() {
It("should return correct count of genres", func() {
count, err := restRepo.Count()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(12))) // We have 12 genre tags
})
It("should handle zero count", func() {
// Clear all genre tags
_, err := GetDBXBuilder().NewQuery("DELETE FROM tag WHERE tag_name = 'genre'").Execute()
Expect(err).ToNot(HaveOccurred())
count, err := restRepo.Count()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(BeZero())
})
It("should only count genre tags", func() {
// Add a non-genre tag
nonGenreTag := model.Tag{
ID: id.NewTagID("mood", "energetic"),
TagName: "mood",
TagValue: "energetic",
}
err := tagRepo.Add(1, nonGenreTag)
Expect(err).ToNot(HaveOccurred())
count, err := restRepo.Count()
Expect(err).ToNot(HaveOccurred())
// Count should not include the mood tag
Expect(count).To(Equal(int64(12))) // Should still be 12 genre tags
})
It("should filter by name using like match", func() {
// Test filtering by partial name match using the "name" filter which maps to containsFilter("tag_value")
options := rest.QueryOptions{
Filters: map[string]interface{}{"name": "%rock%"},
}
count, err := restRepo.Count(options)
Expect(err).ToNot(HaveOccurred())
Expect(count).To(BeNumerically("==", 2))
})
})
Describe("Read", func() {
It("should return existing genre", func() {
// Use one of the existing genres from our consolidated dataset
genreID := id.NewTagID("genre", "rock")
result, err := restRepo.Read(genreID)
Expect(err).ToNot(HaveOccurred())
genre := result.(*model.Genre)
Expect(genre.ID).To(Equal(genreID))
Expect(genre.Name).To(Equal("rock"))
})
It("should return error for non-existent genre", func() {
_, err := restRepo.Read("non-existent-id")
Expect(err).To(HaveOccurred())
})
It("should not return non-genre tags", func() {
moodID := id.NewTagID("mood", "happy") // This exists as a mood tag, not genre
_, err := restRepo.Read(moodID)
Expect(err).To(HaveOccurred()) // Should not find it as a genre
})
})
Describe("ReadAll", func() {
It("should return all genres through ReadAll", func() {
result, err := restRepo.ReadAll()
Expect(err).ToNot(HaveOccurred())
genres := result.(model.Genres)
Expect(genres).To(HaveLen(12)) // We have 12 genre tags
genreNames := make([]string, len(genres))
for i, genre := range genres {
genreNames[i] = genre.Name
}
// Check for some of our consolidated dataset genres
Expect(genreNames).To(ContainElement("rock"))
Expect(genreNames).To(ContainElement("pop"))
Expect(genreNames).To(ContainElement("jazz"))
})
It("should support rest query options", func() {
result, err := restRepo.ReadAll()
Expect(err).ToNot(HaveOccurred())
Expect(result).ToNot(BeNil())
})
})
Describe("EntityName", func() {
It("should return correct entity name", func() {
name := restRepo.EntityName()
Expect(name).To(Equal("tag")) // Genre repository uses tag table
})
})
Describe("NewInstance", func() {
It("should return new genre instance", func() {
instance := restRepo.NewInstance()
Expect(instance).To(BeAssignableToTypeOf(&model.Genre{}))
})
})
})

View File

@@ -2,10 +2,13 @@ package persistence
import ( import (
"context" "context"
"fmt"
"strconv"
"sync" "sync"
"time" "time"
. "github.com/Masterminds/squirrel" . "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
@@ -68,41 +71,78 @@ func (r *libraryRepository) GetPath(id int) (string, error) {
} }
func (r *libraryRepository) Put(l *model.Library) error { func (r *libraryRepository) Put(l *model.Library) error {
cols := map[string]any{ if l.ID == model.DefaultLibraryID {
"name": l.Name, currentLib, err := r.Get(1)
"path": l.Path, // if we are creating it, it's ok.
"remote_path": l.RemotePath, if err == nil { // it exists, so we are updating it
"updated_at": time.Now(), if currentLib.Path != l.Path {
} return fmt.Errorf("%w: path for library with ID 1 cannot be changed", model.ErrValidation)
if l.ID != 0 { }
cols["id"] = l.ID }
} }
sq := Insert(r.tableName).SetMap(cols). var err error
Suffix(`on conflict(id) do update set name = excluded.name, path = excluded.path, l.UpdatedAt = time.Now()
remote_path = excluded.remote_path, updated_at = excluded.updated_at`) if l.ID == 0 {
_, err := r.executeSQL(sq) // Insert with autoassigned ID
l.CreatedAt = time.Now()
err = r.db.Model(l).Insert()
} else {
// Try to update first
cols := map[string]any{
"name": l.Name,
"path": l.Path,
"remote_path": l.RemotePath,
"default_new_users": l.DefaultNewUsers,
"updated_at": l.UpdatedAt,
}
sq := Update(r.tableName).SetMap(cols).Where(Eq{"id": l.ID})
rowsAffected, updateErr := r.executeSQL(sq)
if updateErr != nil {
return updateErr
}
// If no rows were affected, the record doesn't exist, so insert it
if rowsAffected == 0 {
l.CreatedAt = time.Now()
l.UpdatedAt = time.Now()
err = r.db.Model(l).Insert()
}
}
if err != nil { if err != nil {
libLock.Lock() return err
defer libLock.Unlock()
libCache[l.ID] = l.Path
} }
return err
}
const hardCodedMusicFolderID = 1 // Auto-assign all libraries to all admin users
sql := Expr(`
INSERT INTO user_library (user_id, library_id)
SELECT u.id, l.id
FROM user u
CROSS JOIN library l
WHERE u.is_admin = true
ON CONFLICT (user_id, library_id) DO NOTHING;`,
)
if _, err = r.executeSQL(sql); err != nil {
return fmt.Errorf("failed to assign library to admin users: %w", err)
}
libLock.Lock()
defer libLock.Unlock()
libCache[l.ID] = l.Path
return nil
}
// TODO Remove this method when we have a proper UI to add libraries // TODO Remove this method when we have a proper UI to add libraries
// This is a temporary method to store the music folder path from the config in the DB // This is a temporary method to store the music folder path from the config in the DB
func (r *libraryRepository) StoreMusicFolder() error { func (r *libraryRepository) StoreMusicFolder() error {
sq := Update(r.tableName).Set("path", conf.Server.MusicFolder). sq := Update(r.tableName).Set("path", conf.Server.MusicFolder).
Set("updated_at", time.Now()). Set("updated_at", time.Now()).
Where(Eq{"id": hardCodedMusicFolderID}) Where(Eq{"id": model.DefaultLibraryID})
_, err := r.executeSQL(sq) _, err := r.executeSQL(sq)
if err != nil { if err != nil {
libLock.Lock() libLock.Lock()
defer libLock.Unlock() defer libLock.Unlock()
libCache[hardCodedMusicFolderID] = conf.Server.MusicFolder libCache[model.DefaultLibraryID] = conf.Server.MusicFolder
} }
return err return err
} }
@@ -150,6 +190,7 @@ func (r *libraryRepository) ScanInProgress() (bool, error) {
func (r *libraryRepository) RefreshStats(id int) error { func (r *libraryRepository) RefreshStats(id int) error {
var songsRes, albumsRes, artistsRes, foldersRes, filesRes, missingRes struct{ Count int64 } var songsRes, albumsRes, artistsRes, foldersRes, filesRes, missingRes struct{ Count int64 }
var sizeRes struct{ Sum int64 } var sizeRes struct{ Sum int64 }
var durationRes struct{ Sum float64 }
err := run.Parallel( err := run.Parallel(
func() error { func() error {
@@ -180,6 +221,9 @@ func (r *libraryRepository) RefreshStats(id int) error {
func() error { func() error {
return r.queryOne(Select("ifnull(sum(size),0) as sum").From("album").Where(Eq{"library_id": id, "missing": false}), &sizeRes) return r.queryOne(Select("ifnull(sum(size),0) as sum").From("album").Where(Eq{"library_id": id, "missing": false}), &sizeRes)
}, },
func() error {
return r.queryOne(Select("ifnull(sum(duration),0) as sum").From("album").Where(Eq{"library_id": id, "missing": false}), &durationRes)
},
)() )()
if err != nil { if err != nil {
return err return err
@@ -193,12 +237,34 @@ func (r *libraryRepository) RefreshStats(id int) error {
Set("total_files", filesRes.Count). Set("total_files", filesRes.Count).
Set("total_missing_files", missingRes.Count). Set("total_missing_files", missingRes.Count).
Set("total_size", sizeRes.Sum). Set("total_size", sizeRes.Sum).
Set("total_duration", durationRes.Sum).
Set("updated_at", time.Now()). Set("updated_at", time.Now()).
Where(Eq{"id": id}) Where(Eq{"id": id})
_, err = r.executeSQL(sq) _, err = r.executeSQL(sq)
return err return err
} }
func (r *libraryRepository) Delete(id int) error {
if !loggedUser(r.ctx).IsAdmin {
return model.ErrNotAuthorized
}
if id == 1 {
return fmt.Errorf("%w: library with ID 1 cannot be deleted", model.ErrValidation)
}
err := r.delete(Eq{"id": id})
if err != nil {
return err
}
// Clear cache entry for this library only if DB operation was successful
libLock.Lock()
defer libLock.Unlock()
delete(libCache, id)
return nil
}
func (r *libraryRepository) GetAll(ops ...model.QueryOptions) (model.Libraries, error) { func (r *libraryRepository) GetAll(ops ...model.QueryOptions) (model.Libraries, error) {
sq := r.newSelect(ops...).Columns("*") sq := r.newSelect(ops...).Columns("*")
res := model.Libraries{} res := model.Libraries{}
@@ -206,4 +272,72 @@ func (r *libraryRepository) GetAll(ops ...model.QueryOptions) (model.Libraries,
return res, err return res, err
} }
func (r *libraryRepository) CountAll(ops ...model.QueryOptions) (int64, error) {
sq := r.newSelect(ops...)
return r.count(sq)
}
// User-library association methods
func (r *libraryRepository) GetUsersWithLibraryAccess(libraryID int) (model.Users, error) {
sel := Select("u.*").
From("user u").
Join("user_library ul ON u.id = ul.user_id").
Where(Eq{"ul.library_id": libraryID}).
OrderBy("u.name")
var res model.Users
err := r.queryAll(sel, &res)
return res, err
}
// REST interface methods
func (r *libraryRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
func (r *libraryRepository) Read(id string) (interface{}, error) {
idInt, err := strconv.Atoi(id)
if err != nil {
log.Trace(r.ctx, "invalid library id: %s", id, err)
return nil, rest.ErrNotFound
}
return r.Get(idInt)
}
func (r *libraryRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
func (r *libraryRepository) EntityName() string {
return "library"
}
func (r *libraryRepository) NewInstance() interface{} {
return &model.Library{}
}
func (r *libraryRepository) Save(entity interface{}) (string, error) {
lib := entity.(*model.Library)
lib.ID = 0 // Reset ID to ensure we create a new library
err := r.Put(lib)
if err != nil {
return "", err
}
return strconv.Itoa(lib.ID), nil
}
func (r *libraryRepository) Update(id string, entity interface{}, cols ...string) error {
lib := entity.(*model.Library)
idInt, err := strconv.Atoi(id)
if err != nil {
return fmt.Errorf("invalid library ID: %s", id)
}
lib.ID = idInt
return r.Put(lib)
}
var _ model.LibraryRepository = (*libraryRepository)(nil) var _ model.LibraryRepository = (*libraryRepository)(nil)
var _ rest.Repository = (*libraryRepository)(nil)

View File

@@ -22,6 +22,96 @@ var _ = Describe("LibraryRepository", func() {
repo = NewLibraryRepository(ctx, conn) repo = NewLibraryRepository(ctx, conn)
}) })
AfterEach(func() {
// Clean up test libraries (keep ID 1 which is the default library)
_, _ = conn.NewQuery("DELETE FROM library WHERE id > 1").Execute()
})
Describe("Put", func() {
Context("when ID is 0", func() {
It("inserts a new library with autoassigned ID", func() {
lib := &model.Library{
ID: 0,
Name: "Test Library",
Path: "/music/test",
}
err := repo.Put(lib)
Expect(err).ToNot(HaveOccurred())
Expect(lib.ID).To(BeNumerically(">", 0))
Expect(lib.CreatedAt).ToNot(BeZero())
Expect(lib.UpdatedAt).ToNot(BeZero())
// Verify it was inserted
savedLib, err := repo.Get(lib.ID)
Expect(err).ToNot(HaveOccurred())
Expect(savedLib.Name).To(Equal("Test Library"))
Expect(savedLib.Path).To(Equal("/music/test"))
})
})
Context("when ID is non-zero and record exists", func() {
It("updates the existing record", func() {
// First create a library
lib := &model.Library{
ID: 0,
Name: "Original Library",
Path: "/music/original",
}
err := repo.Put(lib)
Expect(err).ToNot(HaveOccurred())
originalID := lib.ID
originalCreatedAt := lib.CreatedAt
// Now update it
lib.Name = "Updated Library"
lib.Path = "/music/updated"
err = repo.Put(lib)
Expect(err).ToNot(HaveOccurred())
// Verify it was updated, not inserted
Expect(lib.ID).To(Equal(originalID))
Expect(lib.CreatedAt).To(Equal(originalCreatedAt))
Expect(lib.UpdatedAt).To(BeTemporally(">", originalCreatedAt))
// Verify the changes were saved
savedLib, err := repo.Get(lib.ID)
Expect(err).ToNot(HaveOccurred())
Expect(savedLib.Name).To(Equal("Updated Library"))
Expect(savedLib.Path).To(Equal("/music/updated"))
})
})
Context("when ID is non-zero but record doesn't exist", func() {
It("inserts a new record with the specified ID", func() {
lib := &model.Library{
ID: 999,
Name: "New Library with ID",
Path: "/music/new",
}
// Ensure the record doesn't exist
_, err := repo.Get(999)
Expect(err).To(HaveOccurred())
// Put should insert it
err = repo.Put(lib)
Expect(err).ToNot(HaveOccurred())
Expect(lib.ID).To(Equal(999))
Expect(lib.CreatedAt).ToNot(BeZero())
Expect(lib.UpdatedAt).ToNot(BeZero())
// Verify it was inserted with the correct ID
savedLib, err := repo.Get(999)
Expect(err).ToNot(HaveOccurred())
Expect(savedLib.ID).To(Equal(999))
Expect(savedLib.Name).To(Equal("New Library with ID"))
Expect(savedLib.Path).To(Equal("/music/new"))
})
})
})
It("refreshes stats", func() { It("refreshes stats", func() {
libBefore, err := repo.Get(1) libBefore, err := repo.Get(1)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
@@ -32,6 +122,7 @@ var _ = Describe("LibraryRepository", func() {
var songsRes, albumsRes, artistsRes, foldersRes, filesRes, missingRes struct{ Count int64 } var songsRes, albumsRes, artistsRes, foldersRes, filesRes, missingRes struct{ Count int64 }
var sizeRes struct{ Sum int64 } var sizeRes struct{ Sum int64 }
var durationRes struct{ Sum float64 }
Expect(conn.NewQuery("select count(*) as count from media_file where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&songsRes)).To(Succeed()) Expect(conn.NewQuery("select count(*) as count from media_file where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&songsRes)).To(Succeed())
Expect(conn.NewQuery("select count(*) as count from album where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&albumsRes)).To(Succeed()) Expect(conn.NewQuery("select count(*) as count from album where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&albumsRes)).To(Succeed())
@@ -40,6 +131,7 @@ var _ = Describe("LibraryRepository", func() {
Expect(conn.NewQuery("select ifnull(sum(num_audio_files + num_playlists + json_array_length(image_files)),0) as count from folder where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&filesRes)).To(Succeed()) Expect(conn.NewQuery("select ifnull(sum(num_audio_files + num_playlists + json_array_length(image_files)),0) as count from folder where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&filesRes)).To(Succeed())
Expect(conn.NewQuery("select count(*) as count from media_file where library_id = {:id} and missing = 1").Bind(dbx.Params{"id": 1}).One(&missingRes)).To(Succeed()) Expect(conn.NewQuery("select count(*) as count from media_file where library_id = {:id} and missing = 1").Bind(dbx.Params{"id": 1}).One(&missingRes)).To(Succeed())
Expect(conn.NewQuery("select ifnull(sum(size),0) as sum from album where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&sizeRes)).To(Succeed()) Expect(conn.NewQuery("select ifnull(sum(size),0) as sum from album where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&sizeRes)).To(Succeed())
Expect(conn.NewQuery("select ifnull(sum(duration),0) as sum from album where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&durationRes)).To(Succeed())
Expect(libAfter.TotalSongs).To(Equal(int(songsRes.Count))) Expect(libAfter.TotalSongs).To(Equal(int(songsRes.Count)))
Expect(libAfter.TotalAlbums).To(Equal(int(albumsRes.Count))) Expect(libAfter.TotalAlbums).To(Equal(int(albumsRes.Count)))
@@ -48,5 +140,6 @@ var _ = Describe("LibraryRepository", func() {
Expect(libAfter.TotalFiles).To(Equal(int(filesRes.Count))) Expect(libAfter.TotalFiles).To(Equal(int(filesRes.Count)))
Expect(libAfter.TotalMissingFiles).To(Equal(int(missingRes.Count))) Expect(libAfter.TotalMissingFiles).To(Equal(int(missingRes.Count)))
Expect(libAfter.TotalSize).To(Equal(sizeRes.Sum)) Expect(libAfter.TotalSize).To(Equal(sizeRes.Sum))
Expect(libAfter.TotalDuration).To(Equal(durationRes.Sum))
}) })
}) })

View File

@@ -96,6 +96,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc {
"genre_id": tagIDFilter, "genre_id": tagIDFilter,
"missing": booleanFilter, "missing": booleanFilter,
"artists_id": artistFilter, "artists_id": artistFilter,
"library_id": libraryIdFilter,
} }
// Add all album tags as filters // Add all album tags as filters
for tag := range model.TagMappings() { for tag := range model.TagMappings() {
@@ -116,6 +117,7 @@ func mediaFileRecentlyAddedSort() string {
func (r *mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) { func (r *mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) {
query := r.newSelect() query := r.newSelect()
query = r.withAnnotation(query, "media_file.id") query = r.withAnnotation(query, "media_file.id")
query = r.applyLibraryFilter(query)
return r.count(query, options...) return r.count(query, options...)
} }
@@ -134,10 +136,11 @@ func (r *mediaFileRepository) Put(m *model.MediaFile) error {
} }
func (r *mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder { func (r *mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder {
sql := r.newSelect(options...).Columns("media_file.*", "library.path as library_path"). sql := r.newSelect(options...).Columns("media_file.*", "library.path as library_path", "library.name as library_name").
LeftJoin("library on media_file.library_id = library.id") LeftJoin("library on media_file.library_id = library.id")
sql = r.withAnnotation(sql, "media_file.id") sql = r.withAnnotation(sql, "media_file.id")
return r.withBookmark(sql, "media_file.id") sql = r.withBookmark(sql, "media_file.id")
return r.applyLibraryFilter(sql)
} }
func (r *mediaFileRepository) Get(id string) (*model.MediaFile, error) { func (r *mediaFileRepository) Get(id string) (*model.MediaFile, error) {
@@ -273,7 +276,7 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC
if err != nil { if err != nil {
return nil, err return nil, err
} }
sel := r.newSelect().Columns("media_file.*", "library.path as library_path"). sel := r.newSelect().Columns("media_file.*", "library.path as library_path", "library.name as library_name").
LeftJoin("library on media_file.library_id = library.id"). LeftJoin("library on media_file.library_id = library.id").
Where("pid in ("+subQText+")", subQArgs...). Where("pid in ("+subQText+")", subQArgs...).
Where(Or{ Where(Or{
@@ -294,15 +297,57 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC
}, nil }, nil
} }
func (r *mediaFileRepository) Search(q string, offset int, size int, includeMissing bool) (model.MediaFiles, error) { // FindRecentFilesByMBZTrackID finds recently added files by MusicBrainz Track ID in other libraries
func (r *mediaFileRepository) FindRecentFilesByMBZTrackID(missing model.MediaFile, since time.Time) (model.MediaFiles, error) {
sel := r.selectMediaFile().Where(And{
NotEq{"media_file.library_id": missing.LibraryID},
Eq{"media_file.mbz_release_track_id": missing.MbzReleaseTrackID},
NotEq{"media_file.mbz_release_track_id": ""}, // Exclude empty MBZ Track IDs
Eq{"media_file.suffix": missing.Suffix},
Gt{"media_file.created_at": since},
Eq{"media_file.missing": false},
}).OrderBy("media_file.created_at DESC")
var res dbMediaFiles
err := r.queryAll(sel, &res)
if err != nil {
return nil, err
}
return res.toModels(), nil
}
// FindRecentFilesByProperties finds recently added files by intrinsic properties in other libraries
func (r *mediaFileRepository) FindRecentFilesByProperties(missing model.MediaFile, since time.Time) (model.MediaFiles, error) {
sel := r.selectMediaFile().Where(And{
NotEq{"media_file.library_id": missing.LibraryID},
Eq{"media_file.title": missing.Title},
Eq{"media_file.size": missing.Size},
Eq{"media_file.suffix": missing.Suffix},
Eq{"media_file.disc_number": missing.DiscNumber},
Eq{"media_file.track_number": missing.TrackNumber},
Eq{"media_file.album": missing.Album},
Eq{"media_file.mbz_release_track_id": ""}, // Exclude files with MBZ Track ID
Gt{"media_file.created_at": since},
Eq{"media_file.missing": false},
}).OrderBy("media_file.created_at DESC")
var res dbMediaFiles
err := r.queryAll(sel, &res)
if err != nil {
return nil, err
}
return res.toModels(), nil
}
func (r *mediaFileRepository) Search(q string, offset int, size int, includeMissing bool, options ...model.QueryOptions) (model.MediaFiles, error) {
var res dbMediaFiles var res dbMediaFiles
if uuid.Validate(q) == nil { if uuid.Validate(q) == nil {
err := r.searchByMBID(r.selectMediaFile(), q, []string{"mbz_recording_id", "mbz_release_track_id"}, includeMissing, &res) err := r.searchByMBID(r.selectMediaFile(options...), q, []string{"mbz_recording_id", "mbz_release_track_id"}, includeMissing, &res)
if err != nil { if err != nil {
return nil, fmt.Errorf("searching media_file by MBID %q: %w", q, err) return nil, fmt.Errorf("searching media_file by MBID %q: %w", q, err)
} }
} else { } else {
err := r.doSearch(r.selectMediaFile(), q, offset, size, includeMissing, &res, "title") err := r.doSearch(r.selectMediaFile(options...), q, offset, size, includeMissing, &res, "title")
if err != nil { if err != nil {
return nil, fmt.Errorf("searching media_file by query %q: %w", q, err) return nil, fmt.Errorf("searching media_file by query %q: %w", q, err)
} }

View File

@@ -34,6 +34,7 @@ func mf(mf model.MediaFile) model.MediaFile {
mf.Tags = model.Tags{} mf.Tags = model.Tags{}
mf.LibraryID = 1 mf.LibraryID = 1
mf.LibraryPath = "music" // Default folder mf.LibraryPath = "music" // Default folder
mf.LibraryName = "Music Library"
mf.Participants = model.Participants{ mf.Participants = model.Participants{
model.RoleArtist: model.ParticipantList{ model.RoleArtist: model.ParticipantList{
model.Participant{Artist: model.Artist{ID: mf.ArtistID, Name: mf.Artist}}, model.Participant{Artist: model.Artist{ID: mf.ArtistID, Name: mf.Artist}},
@@ -47,6 +48,8 @@ func mf(mf model.MediaFile) model.MediaFile {
func al(al model.Album) model.Album { func al(al model.Album) model.Album {
al.LibraryID = 1 al.LibraryID = 1
al.LibraryPath = "music"
al.LibraryName = "Music Library"
al.Discs = model.Discs{} al.Discs = model.Discs{}
al.Tags = model.Tags{} al.Tags = model.Tags{}
al.Participants = model.Participants{} al.Participants = model.Participants{}
@@ -138,14 +141,13 @@ var _ = BeforeSuite(func() {
} }
} }
//gr := NewGenreRepository(ctx, conn) // Associate users with library 1 (default test library)
//for i := range testGenres { for i := range testUsers {
// g := testGenres[i] err := ur.SetUserLibraries(testUsers[i].ID, []int{1})
// err := gr.Put(&g) if err != nil {
// if err != nil { panic(err)
// panic(err) }
// } }
//}
alr := NewAlbumRepository(ctx, conn).(*albumRepository) alr := NewAlbumRepository(ctx, conn).(*albumRepository)
for i := range testAlbums { for i := range testAlbums {
@@ -165,6 +167,15 @@ var _ = BeforeSuite(func() {
} }
} }
// Associate artists with library 1 (default test library)
lr := NewLibraryRepository(ctx, conn)
for i := range testArtists {
err := lr.AddArtist(1, testArtists[i].ID)
if err != nil {
panic(err)
}
}
mr := NewMediaFileRepository(ctx, conn) mr := NewMediaFileRepository(ctx, conn)
for i := range testSongs { for i := range testSongs {
err := mr.Put(&testSongs[i]) err := mr.Put(&testSongs[i])
@@ -190,9 +201,9 @@ var _ = BeforeSuite(func() {
Public: true, Public: true,
SongCount: 2, SongCount: 2,
} }
plsBest.AddTracks([]string{"1001", "1003"}) plsBest.AddMediaFilesByID([]string{"1001", "1003"})
plsCool = model.Playlist{Name: "Cool", OwnerID: "userid", OwnerName: "userid"} plsCool = model.Playlist{Name: "Cool", OwnerID: "userid", OwnerName: "userid"}
plsCool.AddTracks([]string{"1004"}) plsCool.AddMediaFilesByID([]string{"1004"})
testPlaylists = []*model.Playlist{&plsBest, &plsCool} testPlaylists = []*model.Playlist{&plsBest, &plsCool}
pr := NewPlaylistRepository(ctx, conn) pr := NewPlaylistRepository(ctx, conn)

View File

@@ -161,7 +161,7 @@ func (r *playlistRepository) GetWithTracks(id string, refreshSmartPlaylist, incl
log.Error(r.ctx, "Error loading playlist tracks ", "playlist", pls.Name, "id", pls.ID, err) log.Error(r.ctx, "Error loading playlist tracks ", "playlist", pls.Name, "id", pls.ID, err)
return nil, err return nil, err
} }
pls.Tracks = tracks pls.SetTracks(tracks)
return pls, nil return pls, nil
} }
@@ -263,7 +263,7 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
From("media_file").LeftJoin("annotation on (" + From("media_file").LeftJoin("annotation on (" +
"annotation.item_id = media_file.id" + "annotation.item_id = media_file.id" +
" AND annotation.item_type = 'media_file'" + " AND annotation.item_type = 'media_file'" +
" AND annotation.user_id = '" + userId(r.ctx) + "')") " AND annotation.user_id = '" + usr.ID + "')")
sq = r.addCriteria(sq, rules) sq = r.addCriteria(sq, rules)
insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq) insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
_, err = r.executeSQL(insSql) _, err = r.executeSQL(insSql)
@@ -379,6 +379,8 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error {
} }
func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.PlaylistTracks, error) { func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.PlaylistTracks, error) {
sel = r.applyLibraryFilter(sel, "f")
userID := loggedUser(r.ctx).ID
tracksQuery := sel. tracksQuery := sel.
Columns( Columns(
"coalesce(starred, 0) as starred", "coalesce(starred, 0) as starred",
@@ -389,11 +391,12 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla
"f.*", "f.*",
"playlist_tracks.*", "playlist_tracks.*",
"library.path as library_path", "library.path as library_path",
"library.name as library_name",
). ).
LeftJoin("annotation on (" + LeftJoin("annotation on (" +
"annotation.item_id = media_file_id" + "annotation.item_id = media_file_id" +
" AND annotation.item_type = 'media_file'" + " AND annotation.item_type = 'media_file'" +
" AND annotation.user_id = '" + userId(r.ctx) + "')"). " AND annotation.user_id = '" + userID + "')").
Join("media_file f on f.id = media_file_id"). Join("media_file f on f.id = media_file_id").
Join("library on f.library_id = library.id"). Join("library on f.library_id = library.id").
Where(Eq{"playlist_id": id}) Where(Eq{"playlist_id": id})

View File

@@ -79,13 +79,13 @@ var _ = Describe("PlaylistRepository", func() {
It("Put/Exists/Delete", func() { It("Put/Exists/Delete", func() {
By("saves the playlist to the DB") By("saves the playlist to the DB")
newPls := model.Playlist{Name: "Great!", OwnerID: "userid"} newPls := model.Playlist{Name: "Great!", OwnerID: "userid"}
newPls.AddTracks([]string{"1004", "1003"}) newPls.AddMediaFilesByID([]string{"1004", "1003"})
By("saves the playlist to the DB") By("saves the playlist to the DB")
Expect(repo.Put(&newPls)).To(BeNil()) Expect(repo.Put(&newPls)).To(BeNil())
By("adds repeated songs to a playlist and keeps the order") By("adds repeated songs to a playlist and keeps the order")
newPls.AddTracks([]string{"1004"}) newPls.AddMediaFilesByID([]string{"1004"})
Expect(repo.Put(&newPls)).To(BeNil()) Expect(repo.Put(&newPls)).To(BeNil())
saved, _ := repo.GetWithTracks(newPls.ID, true, false) saved, _ := repo.GetWithTracks(newPls.ID, true, false)
Expect(saved.Tracks).To(HaveLen(3)) Expect(saved.Tracks).To(HaveLen(3))

View File

@@ -47,7 +47,8 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
p.db = r.db p.db = r.db
p.tableName = "playlist_tracks" p.tableName = "playlist_tracks"
p.registerModel(&model.PlaylistTrack{}, map[string]filterFunc{ p.registerModel(&model.PlaylistTrack{}, map[string]filterFunc{
"missing": booleanFilter, "missing": booleanFilter,
"library_id": libraryIdFilter,
}) })
p.setSortMappings( p.setSortMappings(
map[string]string{ map[string]string{
@@ -84,11 +85,12 @@ func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, er
} }
func (r *playlistTrackRepository) Read(id string) (interface{}, error) { func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
userID := loggedUser(r.ctx).ID
sel := r.newSelect(). sel := r.newSelect().
LeftJoin("annotation on ("+ LeftJoin("annotation on ("+
"annotation.item_id = media_file_id"+ "annotation.item_id = media_file_id"+
" AND annotation.item_type = 'media_file'"+ " AND annotation.item_type = 'media_file'"+
" AND annotation.user_id = '"+userId(r.ctx)+"')"). " AND annotation.user_id = '"+userID+"')").
Columns( Columns(
"coalesce(starred, 0) as starred", "coalesce(starred, 0) as starred",
"coalesce(play_count, 0) as play_count", "coalesce(play_count, 0) as play_count",

View File

@@ -8,6 +8,7 @@ import (
. "github.com/Masterminds/squirrel" . "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/model/request"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
) )
@@ -82,7 +83,20 @@ func (r *scrobbleBufferRepository) Next(service string, userId string) (*model.S
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Create context with user information for getParticipants call
// This is needed because the artist repository requires user context for multi-library support
userRepo := NewUserRepository(r.ctx, r.db)
user, err := userRepo.Get(res.ScrobbleEntry.UserID)
if err != nil {
return nil, err
}
// Temporarily use user context for getParticipants
originalCtx := r.ctx
r.ctx = request.WithUser(r.ctx, *user)
res.ScrobbleEntry.Participants, err = r.getParticipants(&res.ScrobbleEntry.MediaFile) res.ScrobbleEntry.Participants, err = r.getParticipants(&res.ScrobbleEntry.MediaFile)
r.ctx = originalCtx // Restore original context
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -15,15 +15,14 @@ import (
const annotationTable = "annotation" const annotationTable = "annotation"
func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) SelectBuilder { func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) SelectBuilder {
if userId(r.ctx) == invalidUserId { userID := loggedUser(r.ctx).ID
if userID == invalidUserId {
return query return query
} }
query = query. query = query.
LeftJoin("annotation on ("+ LeftJoin("annotation on ("+
"annotation.item_id = "+idField+ "annotation.item_id = "+idField+
// item_ids are unique across different item_types, so the clause below is not needed " AND annotation.user_id = '"+userID+"')").
//" AND annotation.item_type = '"+r.tableName+"'"+
" AND annotation.user_id = '"+userId(r.ctx)+"')").
Columns( Columns(
"coalesce(starred, 0) as starred", "coalesce(starred, 0) as starred",
"coalesce(rating, 0) as rating", "coalesce(rating, 0) as rating",
@@ -42,8 +41,9 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec
} }
func (r sqlRepository) annId(itemID ...string) And { func (r sqlRepository) annId(itemID ...string) And {
userID := loggedUser(r.ctx).ID
return And{ return And{
Eq{annotationTable + ".user_id": userId(r.ctx)}, Eq{annotationTable + ".user_id": userID},
Eq{annotationTable + ".item_type": r.tableName}, Eq{annotationTable + ".item_type": r.tableName},
Eq{annotationTable + ".item_id": itemID}, Eq{annotationTable + ".item_id": itemID},
} }
@@ -56,8 +56,9 @@ func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...strin
} }
c, err := r.executeSQL(upd) c, err := r.executeSQL(upd)
if c == 0 || errors.Is(err, sql.ErrNoRows) { if c == 0 || errors.Is(err, sql.ErrNoRows) {
userID := loggedUser(r.ctx).ID
for _, itemID := range itemIDs { for _, itemID := range itemIDs {
values["user_id"] = userId(r.ctx) values["user_id"] = userID
values["item_type"] = r.tableName values["item_type"] = r.tableName
values["item_id"] = itemID values["item_id"] = itemID
ins := Insert(annotationTable).SetMap(values) ins := Insert(annotationTable).SetMap(values)
@@ -86,8 +87,9 @@ func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
c, err := r.executeSQL(upd) c, err := r.executeSQL(upd)
if c == 0 || errors.Is(err, sql.ErrNoRows) { if c == 0 || errors.Is(err, sql.ErrNoRows) {
userID := loggedUser(r.ctx).ID
values := map[string]interface{}{} values := map[string]interface{}{}
values["user_id"] = userId(r.ctx) values["user_id"] = userID
values["item_type"] = r.tableName values["item_type"] = r.tableName
values["item_id"] = itemID values["item_id"] = itemID
values["play_count"] = 1 values["play_count"] = 1

View File

@@ -49,27 +49,14 @@ type sqlRepository struct {
const invalidUserId = "-1" const invalidUserId = "-1"
func userId(ctx context.Context) string {
if user, ok := request.UserFrom(ctx); !ok {
return invalidUserId
} else {
return user.ID
}
}
func loggedUser(ctx context.Context) *model.User { func loggedUser(ctx context.Context) *model.User {
if user, ok := request.UserFrom(ctx); !ok { if user, ok := request.UserFrom(ctx); !ok {
return &model.User{} return &model.User{ID: invalidUserId}
} else { } else {
return &user return &user
} }
} }
func isAdmin(ctx context.Context) bool {
user := loggedUser(ctx)
return user.IsAdmin
}
func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) { func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) {
if r.tableName == "" { if r.tableName == "" {
r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.") r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.")
@@ -199,10 +186,52 @@ func (r sqlRepository) applyFilters(sq SelectBuilder, options ...model.QueryOpti
return sq return sq
} }
func (r *sqlRepository) withTableName(filter filterFunc) filterFunc {
return func(field string, value any) Sqlizer {
if r.tableName != "" {
field = r.tableName + "." + field
}
return filter(field, value)
}
}
// libraryIdFilter is a filter function to be added to resources that have a library_id column.
func libraryIdFilter(_ string, value interface{}) Sqlizer {
return Eq{"library_id": value}
}
// applyLibraryFilter adds library filtering to queries for tables that have a library_id column
// This ensures users only see content from libraries they have access to
func (r sqlRepository) applyLibraryFilter(sq SelectBuilder, tableName ...string) SelectBuilder {
user := loggedUser(r.ctx)
// Admin users see all content
if user.IsAdmin {
return sq
}
// Get user's accessible library IDs
userID := loggedUser(r.ctx).ID
if userID == invalidUserId {
// No user context - return empty result set
return sq.Where(Eq{"1": "0"})
}
table := r.tableName
if len(tableName) > 0 {
table = tableName[0]
}
// Use subquery to filter by user's library access
// This approach doesn't require DataStore in context
return sq.Where(Expr(table+".library_id IN ("+
"SELECT ul.library_id FROM user_library ul WHERE ul.user_id = ?)", userID))
}
func (r sqlRepository) seedKey() string { func (r sqlRepository) seedKey() string {
// Seed keys must be all lowercase, or else SQLite3 will encode it, making it not match the seed // Seed keys must be all lowercase, or else SQLite3 will encode it, making it not match the seed
// used in the query. Hashing the user ID and converting it to a hex string will do the trick // used in the query. Hashing the user ID and converting it to a hex string will do the trick
userIDHash := md5.Sum([]byte(userId(r.ctx))) userIDHash := md5.Sum([]byte(loggedUser(r.ctx).ID))
return fmt.Sprintf("%s|%x", r.tableName, userIDHash) return fmt.Sprintf("%s|%x", r.tableName, userIDHash)
} }

View File

@@ -15,21 +15,20 @@ import (
const bookmarkTable = "bookmark" const bookmarkTable = "bookmark"
func (r sqlRepository) withBookmark(query SelectBuilder, idField string) SelectBuilder { func (r sqlRepository) withBookmark(query SelectBuilder, idField string) SelectBuilder {
if userId(r.ctx) == invalidUserId { userID := loggedUser(r.ctx).ID
if userID == invalidUserId {
return query return query
} }
return query. return query.
LeftJoin("bookmark on (" + LeftJoin("bookmark on (" +
"bookmark.item_id = " + idField + "bookmark.item_id = " + idField +
// item_ids are unique across different item_types, so the clause below is not needed " AND bookmark.user_id = '" + userID + "')").
//" AND bookmark.item_type = '" + r.tableName + "'" +
" AND bookmark.user_id = '" + userId(r.ctx) + "')").
Columns("coalesce(position, 0) as bookmark_position") Columns("coalesce(position, 0) as bookmark_position")
} }
func (r sqlRepository) bmkID(itemID ...string) And { func (r sqlRepository) bmkID(itemID ...string) And {
return And{ return And{
Eq{bookmarkTable + ".user_id": userId(r.ctx)}, Eq{bookmarkTable + ".user_id": loggedUser(r.ctx).ID},
Eq{bookmarkTable + ".item_type": r.tableName}, Eq{bookmarkTable + ".item_type": r.tableName},
Eq{bookmarkTable + ".item_id": itemID}, Eq{bookmarkTable + ".item_id": itemID},
} }

View File

@@ -1,12 +1,15 @@
package persistence package persistence
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings" "strings"
. "github.com/Masterminds/squirrel" . "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/pocketbase/dbx"
) )
// Format of a tag in the DB // Format of a tag in the DB
@@ -55,3 +58,106 @@ func tagIDFilter(name string, idValue any) Sqlizer {
}, },
) )
} }
// tagLibraryIdFilter filters tags based on library access through the library_tag table
func tagLibraryIdFilter(_ string, value interface{}) Sqlizer {
return Eq{"library_tag.library_id": value}
}
// baseTagRepository provides common functionality for all tag-based repositories.
// It handles CRUD operations with optional filtering by tag name.
type baseTagRepository struct {
sqlRepository
tagFilter *model.TagName // nil = no filter (all tags), non-nil = filter by specific tag name
}
// newBaseTagRepository creates a new base tag repository with optional tag filtering.
// If tagFilter is nil, the repository will work with all tags.
// If tagFilter is provided, the repository will only work with tags of that specific name.
func newBaseTagRepository(ctx context.Context, db dbx.Builder, tagFilter *model.TagName) *baseTagRepository {
r := &baseTagRepository{
tagFilter: tagFilter,
}
r.ctx = ctx
r.db = db
r.tableName = "tag"
r.registerModel(&model.Tag{}, map[string]filterFunc{
"name": containsFilter("tag_value"),
"library_id": tagLibraryIdFilter,
})
r.setSortMappings(map[string]string{
"name": "tag_value",
})
return r
}
// newSelect overrides the base implementation to apply tag name filtering and library filtering.
func (r *baseTagRepository) newSelect(options ...model.QueryOptions) SelectBuilder {
user := loggedUser(r.ctx)
if user.ID == invalidUserId {
// No user context - return empty result set
return SelectBuilder{}.Where(Eq{"1": "0"})
}
sq := r.sqlRepository.newSelect(options...)
if r.tagFilter != nil {
sq = sq.Where(Eq{"tag.tag_name": *r.tagFilter})
}
sq = sq.Columns(
"tag.id",
"tag.tag_value as name",
"COALESCE(SUM(library_tag.album_count), 0) as album_count",
"COALESCE(SUM(library_tag.media_file_count), 0) as song_count",
).
LeftJoin("library_tag on library_tag.tag_id = tag.id").
// Apply library filtering by joining only with accessible libraries
Join("user_library on user_library.library_id = library_tag.library_id AND user_library.user_id = ?", user.ID).
GroupBy("tag.id", "tag.tag_value")
return sq
}
// ResourceRepository interface implementation
func (r *baseTagRepository) Count(options ...rest.QueryOptions) (int64, error) {
// Create a query that counts distinct tags without GROUP BY
user := loggedUser(r.ctx)
if user.ID == invalidUserId {
return 0, nil
}
// Build the same base query as newSelect but for counting
sq := Select()
if r.tagFilter != nil {
sq = sq.Where(Eq{"tag.tag_name": *r.tagFilter})
}
// Apply the same joins as newSelect
sq = sq.LeftJoin("library_tag on library_tag.tag_id = tag.id").
Join("user_library on user_library.library_id = library_tag.library_id AND user_library.user_id = ?", user.ID)
return r.count(sq, r.parseRestOptions(r.ctx, options...))
}
func (r *baseTagRepository) Read(id string) (interface{}, error) {
query := r.newSelect().Columns("*").Where(Eq{"id": id})
var res model.Tag
err := r.queryOne(query, &res)
return &res, err
}
func (r *baseTagRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
query := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*")
var res model.TagList
err := r.queryAll(query, &res)
return res, err
}
func (r *baseTagRepository) EntityName() string {
return "tag"
}
func (r *baseTagRepository) NewInstance() interface{} {
return model.Tag{}
}
// Interface compliance check
var _ model.ResourceRepository = (*baseTagRepository)(nil)

View File

@@ -0,0 +1,228 @@
package persistence
import (
"context"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
)
var _ = Describe("Tag Library Filtering", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
// Clean up all relevant tables
db := GetDBXBuilder()
_, err := db.NewQuery("DELETE FROM library_tag").Execute()
Expect(err).ToNot(HaveOccurred())
_, err = db.NewQuery("DELETE FROM tag").Execute()
Expect(err).ToNot(HaveOccurred())
_, err = db.NewQuery("DELETE FROM user_library WHERE user_id != 'userid' AND user_id != '2222'").Execute()
Expect(err).ToNot(HaveOccurred())
_, err = db.NewQuery("DELETE FROM library WHERE id > 1").Execute()
Expect(err).ToNot(HaveOccurred())
// Create test libraries
_, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES (2, 'Library 2', '/music/lib2')").Execute()
Expect(err).ToNot(HaveOccurred())
_, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES (3, 'Library 3', '/music/lib3')").Execute()
Expect(err).ToNot(HaveOccurred())
// Ensure admin user has access to all libraries (since admin users should have access to all libraries)
_, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ('userid', 1)").Execute()
Expect(err).ToNot(HaveOccurred())
_, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ('userid', 2)").Execute()
Expect(err).ToNot(HaveOccurred())
_, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ('userid', 3)").Execute()
Expect(err).ToNot(HaveOccurred())
// Set up test tags
newTag := func(name, value string) model.Tag {
return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value}
}
// Create tags in admin context
adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser)
tagRepo := NewTagRepository(adminCtx, GetDBXBuilder())
// Add tags to different libraries
err = tagRepo.Add(1, newTag("genre", "rock"))
Expect(err).ToNot(HaveOccurred())
err = tagRepo.Add(2, newTag("genre", "pop"))
Expect(err).ToNot(HaveOccurred())
err = tagRepo.Add(3, newTag("genre", "jazz"))
Expect(err).ToNot(HaveOccurred())
err = tagRepo.Add(2, newTag("genre", "rock"))
Expect(err).ToNot(HaveOccurred())
// Update counts manually for testing
_, err = db.NewQuery("UPDATE library_tag SET album_count = 5, media_file_count = 20 WHERE tag_id = {:tagId} AND library_id = 1").Bind(dbx.Params{"tagId": id.NewTagID("genre", "rock")}).Execute()
Expect(err).ToNot(HaveOccurred())
_, err = db.NewQuery("UPDATE library_tag SET album_count = 3, media_file_count = 10 WHERE tag_id = {:tagId} AND library_id = 2").Bind(dbx.Params{"tagId": id.NewTagID("genre", "pop")}).Execute()
Expect(err).ToNot(HaveOccurred())
_, err = db.NewQuery("UPDATE library_tag SET album_count = 2, media_file_count = 8 WHERE tag_id = {:tagId} AND library_id = 3").Bind(dbx.Params{"tagId": id.NewTagID("genre", "jazz")}).Execute()
Expect(err).ToNot(HaveOccurred())
_, err = db.NewQuery("UPDATE library_tag SET album_count = 1, media_file_count = 4 WHERE tag_id = {:tagId} AND library_id = 2").Bind(dbx.Params{"tagId": id.NewTagID("genre", "rock")}).Execute()
Expect(err).ToNot(HaveOccurred())
// Set up user library access - Regular user has access to libraries 1 and 2 only
_, err = db.NewQuery("INSERT INTO user_library (user_id, library_id) VALUES ('2222', 2)").Execute()
Expect(err).ToNot(HaveOccurred())
})
Describe("TagRepository Library Filtering", func() {
Context("Admin User", func() {
It("should see all tags regardless of library", func() {
ctx := request.WithUser(log.NewContext(context.TODO()), adminUser)
tagRepo := NewTagRepository(ctx, GetDBXBuilder())
repo := tagRepo.(model.ResourceRepository)
tags, err := repo.ReadAll()
Expect(err).ToNot(HaveOccurred())
tagList := tags.(model.TagList)
Expect(tagList).To(HaveLen(3))
})
})
Context("Regular User with Limited Library Access", func() {
It("should only see tags from accessible libraries", func() {
ctx := request.WithUser(log.NewContext(context.TODO()), regularUser)
tagRepo := NewTagRepository(ctx, GetDBXBuilder())
repo := tagRepo.(model.ResourceRepository)
tags, err := repo.ReadAll()
Expect(err).ToNot(HaveOccurred())
tagList := tags.(model.TagList)
// Should see rock (libraries 1,2) and pop (library 2), but not jazz (library 3)
Expect(tagList).To(HaveLen(2))
})
It("should respect explicit library_id filters within accessible libraries", func() {
ctx := request.WithUser(log.NewContext(context.TODO()), regularUser)
tagRepo := NewTagRepository(ctx, GetDBXBuilder())
repo := tagRepo.(model.ResourceRepository)
// Filter by library 2 (user has access to libraries 1 and 2)
tags, err := repo.ReadAll(rest.QueryOptions{
Filters: map[string]interface{}{
"library_id": 2,
},
})
Expect(err).ToNot(HaveOccurred())
tagList := tags.(model.TagList)
// Should see only tags from library 2: pop and rock(lib2)
Expect(tagList).To(HaveLen(2))
// Verify the tags are correct
tagValues := make([]string, len(tagList))
for i, tag := range tagList {
tagValues[i] = tag.TagValue
}
Expect(tagValues).To(ContainElements("pop", "rock"))
})
It("should not return tags when filtering by inaccessible library", func() {
ctx := request.WithUser(log.NewContext(context.TODO()), regularUser)
tagRepo := NewTagRepository(ctx, GetDBXBuilder())
repo := tagRepo.(model.ResourceRepository)
// Try to filter by library 3 (user doesn't have access)
tags, err := repo.ReadAll(rest.QueryOptions{
Filters: map[string]interface{}{
"library_id": 3,
},
})
Expect(err).ToNot(HaveOccurred())
tagList := tags.(model.TagList)
// Should return no tags since user can't access library 3
Expect(tagList).To(HaveLen(0))
})
It("should filter by library 1 correctly", func() {
ctx := request.WithUser(log.NewContext(context.TODO()), regularUser)
tagRepo := NewTagRepository(ctx, GetDBXBuilder())
repo := tagRepo.(model.ResourceRepository)
// Filter by library 1 (user has access)
tags, err := repo.ReadAll(rest.QueryOptions{
Filters: map[string]interface{}{
"library_id": 1,
},
})
Expect(err).ToNot(HaveOccurred())
tagList := tags.(model.TagList)
// Should see only rock from library 1
Expect(tagList).To(HaveLen(1))
Expect(tagList[0].TagValue).To(Equal("rock"))
})
})
Context("Admin User with Explicit Library Filtering", func() {
It("should see all tags when no filter is applied", func() {
adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser)
tagRepo := NewTagRepository(adminCtx, GetDBXBuilder())
repo := tagRepo.(model.ResourceRepository)
tags, err := repo.ReadAll()
Expect(err).ToNot(HaveOccurred())
tagList := tags.(model.TagList)
Expect(tagList).To(HaveLen(3))
})
It("should respect explicit library_id filters", func() {
adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser)
tagRepo := NewTagRepository(adminCtx, GetDBXBuilder())
repo := tagRepo.(model.ResourceRepository)
// Filter by library 3
tags, err := repo.ReadAll(rest.QueryOptions{
Filters: map[string]interface{}{
"library_id": 3,
},
})
Expect(err).ToNot(HaveOccurred())
tagList := tags.(model.TagList)
// Should see only jazz from library 3
Expect(tagList).To(HaveLen(1))
Expect(tagList[0].TagValue).To(Equal("jazz"))
})
It("should filter by library 2 correctly", func() {
adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser)
tagRepo := NewTagRepository(adminCtx, GetDBXBuilder())
repo := tagRepo.(model.ResourceRepository)
// Filter by library 2
tags, err := repo.ReadAll(rest.QueryOptions{
Filters: map[string]interface{}{
"library_id": 2,
},
})
Expect(err).ToNot(HaveOccurred())
tagList := tags.(model.TagList)
// Should see pop and rock from library 2
Expect(tagList).To(HaveLen(2))
tagValues := make([]string, len(tagList))
for i, tag := range tagList {
tagValues[i] = tag.TagValue
}
Expect(tagValues).To(ContainElements("pop", "rock"))
})
})
})
})

View File

@@ -7,26 +7,22 @@ import (
"time" "time"
. "github.com/Masterminds/squirrel" . "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
) )
type tagRepository struct { type tagRepository struct {
sqlRepository *baseTagRepository
} }
func NewTagRepository(ctx context.Context, db dbx.Builder) model.TagRepository { func NewTagRepository(ctx context.Context, db dbx.Builder) model.TagRepository {
r := &tagRepository{} return &tagRepository{
r.ctx = ctx baseTagRepository: newBaseTagRepository(ctx, db, nil), // nil = no filter, works with all tags
r.db = db }
r.tableName = "tag"
r.registerModel(&model.Tag{}, nil)
return r
} }
func (r *tagRepository) Add(tags ...model.Tag) error { func (r *tagRepository) Add(libraryID int, tags ...model.Tag) error {
for chunk := range slices.Chunk(tags, 200) { for chunk := range slices.Chunk(tags, 200) {
sq := Insert(r.tableName).Columns("id", "tag_name", "tag_value"). sq := Insert(r.tableName).Columns("id", "tag_name", "tag_value").
Suffix("on conflict (id) do nothing") Suffix("on conflict (id) do nothing")
@@ -37,34 +33,41 @@ func (r *tagRepository) Add(tags ...model.Tag) error {
if err != nil { if err != nil {
return err return err
} }
// Create library_tag entries for library filtering
libSq := Insert("library_tag").Columns("tag_id", "library_id", "album_count", "media_file_count").
Suffix("on conflict (tag_id, library_id) do nothing")
for _, t := range chunk {
libSq = libSq.Values(t.ID, libraryID, 0, 0)
}
_, err = r.executeSQL(libSq)
if err != nil {
return fmt.Errorf("adding library_tag entries: %w", err)
}
} }
return nil return nil
} }
// UpdateCounts updates the album_count and media_file_count columns in the tag_counts table. // UpdateCounts updates the library_tag table with per-library statistics.
// Only genres are being updated for now. // Only genres are being updated for now.
func (r *tagRepository) UpdateCounts() error { func (r *tagRepository) UpdateCounts() error {
template := ` template := `
with updated_values as ( INSERT INTO library_tag (tag_id, library_id, %[1]s_count)
select jt.value as id, count(distinct %[1]s.id) as %[1]s_count SELECT jt.value as tag_id, %[1]s.library_id, count(distinct %[1]s.id) as %[1]s_count
from %[1]s FROM %[1]s
join json_tree(tags, '$.genre') as jt JOIN json_tree(%[1]s.tags, '$.genre') as jt ON jt.atom IS NOT NULL AND jt.key = 'id'
where atom is not null GROUP BY jt.value, %[1]s.library_id
and key = 'id' ON CONFLICT (tag_id, library_id)
group by jt.value DO UPDATE SET %[1]s_count = excluded.%[1]s_count;
)
update tag
set %[1]s_count = updated_values.%[1]s_count
from updated_values
where tag.id = updated_values.id;
` `
for _, table := range []string{"album", "media_file"} { for _, table := range []string{"album", "media_file"} {
start := time.Now() start := time.Now()
query := Expr(fmt.Sprintf(template, table)) query := Expr(fmt.Sprintf(template, table))
c, err := r.executeSQL(query) c, err := r.executeSQL(query)
log.Debug(r.ctx, "Updated tag counts", "table", table, "elapsed", time.Since(start), "updated", c) log.Debug(r.ctx, "Updated library tag counts", "table", table, "elapsed", time.Since(start), "updated", c)
if err != nil { if err != nil {
return fmt.Errorf("updating %s tag counts: %w", table, err) return fmt.Errorf("updating %s library tag counts: %w", table, err)
} }
} }
return nil return nil
@@ -74,6 +77,11 @@ func (r *tagRepository) purgeUnused() error {
del := Delete(r.tableName).Where(` del := Delete(r.tableName).Where(`
id not in (select jt.value id not in (select jt.value
from album left join json_tree(album.tags, '$') as jt from album left join json_tree(album.tags, '$') as jt
where atom is not null
and key = 'id'
UNION
select jt.value
from media_file left join json_tree(media_file.tags, '$') as jt
where atom is not null where atom is not null
and key = 'id') and key = 'id')
`) `)
@@ -87,30 +95,4 @@ func (r *tagRepository) purgeUnused() error {
return err return err
} }
func (r *tagRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.count(r.newSelect(), r.parseRestOptions(r.ctx, options...))
}
func (r *tagRepository) Read(id string) (interface{}, error) {
query := r.newSelect().Columns("*").Where(Eq{"id": id})
var res model.Tag
err := r.queryOne(query, &res)
return &res, err
}
func (r *tagRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
query := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*")
var res model.TagList
err := r.queryAll(query, &res)
return res, err
}
func (r *tagRepository) EntityName() string {
return "tag"
}
func (r *tagRepository) NewInstance() interface{} {
return model.Tag{}
}
var _ model.ResourceRepository = &tagRepository{} var _ model.ResourceRepository = &tagRepository{}

View File

@@ -0,0 +1,249 @@
package persistence
import (
"context"
"slices"
"strings"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("TagRepository", func() {
var repo model.TagRepository
var restRepo model.ResourceRepository
var ctx context.Context
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true})
tagRepo := NewTagRepository(ctx, GetDBXBuilder())
repo = tagRepo
restRepo = tagRepo.(model.ResourceRepository)
// Clean the database before each test to ensure isolation
db := GetDBXBuilder()
_, err := db.NewQuery("DELETE FROM tag").Execute()
Expect(err).ToNot(HaveOccurred())
_, err = db.NewQuery("DELETE FROM library_tag").Execute()
Expect(err).ToNot(HaveOccurred())
// Ensure library 1 exists (if it doesn't already)
_, err = db.NewQuery("INSERT OR IGNORE INTO library (id, name, path, default_new_users) VALUES (1, 'Test Library', '/test', true)").Execute()
Expect(err).ToNot(HaveOccurred())
// Ensure the admin user has access to library 1
_, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ('userid', 1)").Execute()
Expect(err).ToNot(HaveOccurred())
// Add comprehensive test data that covers all test scenarios
newTag := func(name, value string) model.Tag {
return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value}
}
err = repo.Add(1,
// Genre tags
newTag("genre", "rock"),
newTag("genre", "pop"),
newTag("genre", "jazz"),
newTag("genre", "electronic"),
newTag("genre", "classical"),
newTag("genre", "ambient"),
newTag("genre", "techno"),
newTag("genre", "house"),
newTag("genre", "trance"),
newTag("genre", "Alternative Rock"),
newTag("genre", "Blues"),
newTag("genre", "Country"),
// Mood tags
newTag("mood", "happy"),
newTag("mood", "sad"),
newTag("mood", "energetic"),
newTag("mood", "calm"),
// Other tag types
newTag("instrument", "guitar"),
newTag("instrument", "piano"),
newTag("decade", "1980s"),
newTag("decade", "1990s"),
)
Expect(err).ToNot(HaveOccurred())
})
Describe("Add", func() {
It("should handle adding new tags", func() {
newTag := model.Tag{
ID: id.NewTagID("genre", "experimental"),
TagName: "genre",
TagValue: "experimental",
}
err := repo.Add(1, newTag)
Expect(err).ToNot(HaveOccurred())
// Verify tag was added
result, err := restRepo.Read(newTag.ID)
Expect(err).ToNot(HaveOccurred())
resultTag := result.(*model.Tag)
Expect(resultTag.TagValue).To(Equal("experimental"))
// Check count increased
count, err := restRepo.Count()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(21))) // 20 from dataset + 1 new
})
It("should handle duplicate tags gracefully", func() {
// Try to add a duplicate tag
duplicateTag := model.Tag{
ID: id.NewTagID("genre", "rock"), // This already exists
TagName: "genre",
TagValue: "rock",
}
count, err := restRepo.Count()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(20))) // Still 20 tags
err = repo.Add(1, duplicateTag)
Expect(err).ToNot(HaveOccurred()) // Should not error
// Count should remain the same
count, err = restRepo.Count()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(20))) // Still 20 tags
})
})
Describe("UpdateCounts", func() {
It("should update tag counts successfully", func() {
err := repo.UpdateCounts()
Expect(err).ToNot(HaveOccurred())
})
It("should handle empty database gracefully", func() {
// Clear the database first
db := GetDBXBuilder()
_, err := db.NewQuery("DELETE FROM tag").Execute()
Expect(err).ToNot(HaveOccurred())
err = repo.UpdateCounts()
Expect(err).ToNot(HaveOccurred())
})
})
Describe("Count", func() {
It("should return correct count of tags", func() {
count, err := restRepo.Count()
Expect(err).ToNot(HaveOccurred())
Expect(count).To(Equal(int64(20))) // From the test dataset
})
})
Describe("Read", func() {
It("should return existing tag", func() {
rockID := id.NewTagID("genre", "rock")
result, err := restRepo.Read(rockID)
Expect(err).ToNot(HaveOccurred())
resultTag := result.(*model.Tag)
Expect(resultTag.ID).To(Equal(rockID))
Expect(resultTag.TagName).To(Equal(model.TagName("genre")))
Expect(resultTag.TagValue).To(Equal("rock"))
})
It("should return error for non-existent tag", func() {
_, err := restRepo.Read("non-existent-id")
Expect(err).To(HaveOccurred())
})
})
Describe("ReadAll", func() {
It("should return all tags from dataset", func() {
result, err := restRepo.ReadAll()
Expect(err).ToNot(HaveOccurred())
tags := result.(model.TagList)
Expect(tags).To(HaveLen(20))
})
It("should filter tags by partial value correctly", func() {
options := rest.QueryOptions{
Filters: map[string]interface{}{"name": "%rock%"}, // Tags containing 'rock'
}
result, err := restRepo.ReadAll(options)
Expect(err).ToNot(HaveOccurred())
tags := result.(model.TagList)
Expect(tags).To(HaveLen(2)) // "rock" and "Alternative Rock"
// Verify all returned tags contain 'rock' in their value
for _, tag := range tags {
Expect(strings.ToLower(tag.TagValue)).To(ContainSubstring("rock"))
}
})
It("should filter tags by partial value using LIKE", func() {
options := rest.QueryOptions{
Filters: map[string]interface{}{"name": "%e%"}, // Tags containing 'e'
}
result, err := restRepo.ReadAll(options)
Expect(err).ToNot(HaveOccurred())
tags := result.(model.TagList)
Expect(tags).To(HaveLen(8)) // electronic, house, trance, energetic, Blues, decade x2, Alternative Rock
// Verify all returned tags contain 'e' in their value
for _, tag := range tags {
Expect(strings.ToLower(tag.TagValue)).To(ContainSubstring("e"))
}
})
It("should sort tags by value ascending", func() {
options := rest.QueryOptions{
Filters: map[string]interface{}{"name": "%r%"}, // Tags containing 'r'
Sort: "name",
Order: "asc",
}
result, err := restRepo.ReadAll(options)
Expect(err).ToNot(HaveOccurred())
tags := result.(model.TagList)
Expect(tags).To(HaveLen(7))
Expect(slices.IsSortedFunc(tags, func(a, b model.Tag) int {
return strings.Compare(strings.ToLower(a.TagValue), strings.ToLower(b.TagValue))
}))
})
It("should sort tags by value descending", func() {
options := rest.QueryOptions{
Filters: map[string]interface{}{"name": "%r%"}, // Tags containing 'r'
Sort: "name",
Order: "desc",
}
result, err := restRepo.ReadAll(options)
Expect(err).ToNot(HaveOccurred())
tags := result.(model.TagList)
Expect(tags).To(HaveLen(7))
Expect(slices.IsSortedFunc(tags, func(a, b model.Tag) int {
return strings.Compare(strings.ToLower(b.TagValue), strings.ToLower(a.TagValue)) // Descending order
}))
})
})
Describe("EntityName", func() {
It("should return correct entity name", func() {
name := restRepo.EntityName()
Expect(name).To(Equal("tag"))
})
})
Describe("NewInstance", func() {
It("should return new tag instance", func() {
instance := restRepo.NewInstance()
Expect(instance).To(BeAssignableToTypeOf(model.Tag{}))
})
})
})

View File

@@ -41,7 +41,7 @@ func (r *transcodingRepository) FindByFormat(format string) (*model.Transcoding,
} }
func (r *transcodingRepository) Put(t *model.Transcoding) error { func (r *transcodingRepository) Put(t *model.Transcoding) error {
if !isAdmin(r.ctx) { if !loggedUser(r.ctx).IsAdmin {
return rest.ErrPermissionDenied return rest.ErrPermissionDenied
} }
_, err := r.put(t.ID, t) _, err := r.put(t.ID, t)
@@ -72,7 +72,7 @@ func (r *transcodingRepository) NewInstance() interface{} {
} }
func (r *transcodingRepository) Save(entity interface{}) (string, error) { func (r *transcodingRepository) Save(entity interface{}) (string, error) {
if !isAdmin(r.ctx) { if !loggedUser(r.ctx).IsAdmin {
return "", rest.ErrPermissionDenied return "", rest.ErrPermissionDenied
} }
t := entity.(*model.Transcoding) t := entity.(*model.Transcoding)
@@ -84,7 +84,7 @@ func (r *transcodingRepository) Save(entity interface{}) (string, error) {
} }
func (r *transcodingRepository) Update(id string, entity interface{}, cols ...string) error { func (r *transcodingRepository) Update(id string, entity interface{}, cols ...string) error {
if !isAdmin(r.ctx) { if !loggedUser(r.ctx).IsAdmin {
return rest.ErrPermissionDenied return rest.ErrPermissionDenied
} }
t := entity.(*model.Transcoding) t := entity.(*model.Transcoding)
@@ -97,7 +97,7 @@ func (r *transcodingRepository) Update(id string, entity interface{}, cols ...st
} }
func (r *transcodingRepository) Delete(id string) error { func (r *transcodingRepository) Delete(id string) error {
if !isAdmin(r.ctx) { if !loggedUser(r.ctx).IsAdmin {
return rest.ErrPermissionDenied return rest.ErrPermissionDenied
} }
err := r.delete(Eq{"id": id}) err := r.delete(Eq{"id": id})

View File

@@ -3,6 +3,7 @@ package persistence
import ( import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -17,6 +18,7 @@ import (
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/slice"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
) )
@@ -24,6 +26,26 @@ type userRepository struct {
sqlRepository sqlRepository
} }
type dbUser struct {
*model.User `structs:",flatten"`
LibrariesJSON string `structs:"-" json:"-"`
}
func (u *dbUser) PostScan() error {
if u.LibrariesJSON != "" {
if err := json.Unmarshal([]byte(u.LibrariesJSON), &u.User.Libraries); err != nil {
return fmt.Errorf("parsing user libraries from db: %w", err)
}
}
return nil
}
type dbUsers []dbUser
func (us dbUsers) toModels() model.Users {
return slice.Map(us, func(u dbUser) model.User { return *u.User })
}
var ( var (
once sync.Once once sync.Once
encKey []byte encKey []byte
@@ -33,8 +55,10 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository
r := &userRepository{} r := &userRepository{}
r.ctx = ctx r.ctx = ctx
r.db = db r.db = db
r.tableName = "user"
r.registerModel(&model.User{}, map[string]filterFunc{ r.registerModel(&model.User{}, map[string]filterFunc{
"password": invalidFilter(ctx), "password": invalidFilter(ctx),
"name": r.withTableName(startsWithFilter),
}) })
once.Do(func() { once.Do(func() {
_ = r.initPasswordEncryptionKey() _ = r.initPasswordEncryptionKey()
@@ -42,28 +66,48 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository
return r return r
} }
// selectUserWithLibraries returns a SelectBuilder that includes library information
func (r *userRepository) selectUserWithLibraries(options ...model.QueryOptions) SelectBuilder {
return r.newSelect(options...).
Columns(`user.*`,
`COALESCE(json_group_array(json_object(
'id', library.id,
'name', library.name,
'path', library.path,
'remote_path', library.remote_path,
'last_scan_at', library.last_scan_at,
'last_scan_started_at', library.last_scan_started_at,
'full_scan_in_progress', library.full_scan_in_progress,
'updated_at', library.updated_at,
'created_at', library.created_at
)) FILTER (WHERE library.id IS NOT NULL), '[]') AS libraries_json`).
LeftJoin("user_library ul ON user.id = ul.user_id").
LeftJoin("library ON ul.library_id = library.id").
GroupBy("user.id")
}
func (r *userRepository) CountAll(qo ...model.QueryOptions) (int64, error) { func (r *userRepository) CountAll(qo ...model.QueryOptions) (int64, error) {
return r.count(Select(), qo...) return r.count(Select(), qo...)
} }
func (r *userRepository) Get(id string) (*model.User, error) { func (r *userRepository) Get(id string) (*model.User, error) {
sel := r.newSelect().Columns("*").Where(Eq{"id": id}) sel := r.selectUserWithLibraries().Where(Eq{"user.id": id})
var res model.User var res dbUser
err := r.queryOne(sel, &res) err := r.queryOne(sel, &res)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &res, nil return res.User, nil
} }
func (r *userRepository) GetAll(options ...model.QueryOptions) (model.Users, error) { func (r *userRepository) GetAll(options ...model.QueryOptions) (model.Users, error) {
sel := r.newSelect(options...).Columns("*") sel := r.selectUserWithLibraries(options...)
res := model.Users{} var res dbUsers
err := r.queryAll(sel, &res) err := r.queryAll(sel, &res)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return res, nil return res.toModels(), nil
} }
func (r *userRepository) Put(u *model.User) error { func (r *userRepository) Put(u *model.User) error {
@@ -79,38 +123,65 @@ func (r *userRepository) Put(u *model.User) error {
return fmt.Errorf("error converting user to SQL args: %w", err) return fmt.Errorf("error converting user to SQL args: %w", err)
} }
delete(values, "current_password") delete(values, "current_password")
// Save/update the user
update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values) update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values)
count, err := r.executeSQL(update) count, err := r.executeSQL(update)
if err != nil { if err != nil {
return err return err
} }
if count > 0 {
return nil isNewUser := count == 0
if isNewUser {
values["created_at"] = time.Now()
insert := Insert(r.tableName).SetMap(values)
_, err = r.executeSQL(insert)
if err != nil {
return err
}
} }
values["created_at"] = time.Now()
insert := Insert(r.tableName).SetMap(values) // Auto-assign all libraries to admin users in a single SQL operation
_, err = r.executeSQL(insert) if u.IsAdmin {
return err sql := Expr(
"INSERT OR IGNORE INTO user_library (user_id, library_id) SELECT ?, id FROM library",
u.ID,
)
if _, err := r.executeSQL(sql); err != nil {
return fmt.Errorf("failed to assign all libraries to admin user: %w", err)
}
} else if isNewUser { // Only for new regular users
// Auto-assign default libraries to new regular users
sql := Expr(
"INSERT OR IGNORE INTO user_library (user_id, library_id) SELECT ?, id FROM library WHERE default_new_users = true",
u.ID,
)
if _, err := r.executeSQL(sql); err != nil {
return fmt.Errorf("failed to assign default libraries to new user: %w", err)
}
}
return nil
} }
func (r *userRepository) FindFirstAdmin() (*model.User, error) { func (r *userRepository) FindFirstAdmin() (*model.User, error) {
sel := r.newSelect(model.QueryOptions{Sort: "updated_at", Max: 1}).Columns("*").Where(Eq{"is_admin": true}) sel := r.selectUserWithLibraries(model.QueryOptions{Sort: "updated_at", Max: 1}).Where(Eq{"user.is_admin": true})
var usr model.User var usr dbUser
err := r.queryOne(sel, &usr) err := r.queryOne(sel, &usr)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &usr, nil return usr.User, nil
} }
func (r *userRepository) FindByUsername(username string) (*model.User, error) { func (r *userRepository) FindByUsername(username string) (*model.User, error) {
sel := r.newSelect().Columns("*").Where(Expr("user_name = ? COLLATE NOCASE", username)) sel := r.selectUserWithLibraries().Where(Expr("user.user_name = ? COLLATE NOCASE", username))
var usr model.User var usr dbUser
err := r.queryOne(sel, &usr) err := r.queryOne(sel, &usr)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &usr, nil return usr.User, nil
} }
func (r *userRepository) FindByUsernameWithPassword(username string) (*model.User, error) { func (r *userRepository) FindByUsernameWithPassword(username string) (*model.User, error) {
@@ -365,6 +436,39 @@ func (r *userRepository) decryptAllPasswords(users model.Users) error {
return nil return nil
} }
// Library association methods
func (r *userRepository) GetUserLibraries(userID string) (model.Libraries, error) {
sel := Select("l.*").
From("library l").
Join("user_library ul ON l.id = ul.library_id").
Where(Eq{"ul.user_id": userID}).
OrderBy("l.name")
var res model.Libraries
err := r.queryAll(sel, &res)
return res, err
}
func (r *userRepository) SetUserLibraries(userID string, libraryIDs []int) error {
// Remove existing associations
delSql := Delete("user_library").Where(Eq{"user_id": userID})
if _, err := r.executeSQL(delSql); err != nil {
return err
}
// Add new associations
if len(libraryIDs) > 0 {
insert := Insert("user_library").Columns("user_id", "library_id")
for _, libID := range libraryIDs {
insert = insert.Values(userID, libID)
}
_, err := r.executeSQL(insert)
return err
}
return nil
}
var _ model.UserRepository = (*userRepository)(nil) var _ model.UserRepository = (*userRepository)(nil)
var _ rest.Repository = (*userRepository)(nil) var _ rest.Repository = (*userRepository)(nil)
var _ rest.Persistable = (*userRepository)(nil) var _ rest.Persistable = (*userRepository)(nil)

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest" "github.com/deluan/rest"
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
@@ -235,4 +236,330 @@ var _ = Describe("UserRepository", func() {
Expect(err).To(MatchError("fake error")) Expect(err).To(MatchError("fake error"))
}) })
}) })
Describe("Library Association Methods", func() {
var userID string
var library1, library2 model.Library
BeforeEach(func() {
// Create a test user first to satisfy foreign key constraints
testUser := model.User{
ID: "test-user-id",
UserName: "testuser",
Name: "Test User",
Email: "test@example.com",
NewPassword: "password",
IsAdmin: false,
}
Expect(repo.Put(&testUser)).To(BeNil())
userID = testUser.ID
library1 = model.Library{ID: 0, Name: "Library 500", Path: "/path/500"}
library2 = model.Library{ID: 0, Name: "Library 501", Path: "/path/501"}
// Create test libraries
libRepo := NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder())
Expect(libRepo.Put(&library1)).To(BeNil())
Expect(libRepo.Put(&library2)).To(BeNil())
})
AfterEach(func() {
// Clean up user-library associations to ensure test isolation
_ = repo.SetUserLibraries(userID, []int{})
// Clean up test libraries to ensure isolation between test groups
libRepo := NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder())
_ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}})
})
Describe("GetUserLibraries", func() {
It("returns empty list when user has no library associations", func() {
libraries, err := repo.GetUserLibraries("non-existent-user")
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(0))
})
It("returns user's associated libraries", func() {
err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID})
Expect(err).To(BeNil())
libraries, err := repo.GetUserLibraries(userID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(2))
libIDs := []int{libraries[0].ID, libraries[1].ID}
Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
})
})
Describe("SetUserLibraries", func() {
It("sets user's library associations", func() {
libraryIDs := []int{library1.ID, library2.ID}
err := repo.SetUserLibraries(userID, libraryIDs)
Expect(err).To(BeNil())
libraries, err := repo.GetUserLibraries(userID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(2))
})
It("replaces existing associations", func() {
// Set initial associations
err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID})
Expect(err).To(BeNil())
// Replace with just one library
err = repo.SetUserLibraries(userID, []int{library1.ID})
Expect(err).To(BeNil())
libraries, err := repo.GetUserLibraries(userID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(1))
Expect(libraries[0].ID).To(Equal(library1.ID))
})
It("removes all associations when passed empty slice", func() {
// Set initial associations
err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID})
Expect(err).To(BeNil())
// Remove all
err = repo.SetUserLibraries(userID, []int{})
Expect(err).To(BeNil())
libraries, err := repo.GetUserLibraries(userID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(0))
})
})
})
Describe("Admin User Auto-Assignment", func() {
var (
libRepo model.LibraryRepository
library1 model.Library
library2 model.Library
initialLibCount int
)
BeforeEach(func() {
libRepo = NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder())
// Count initial libraries
existingLibs, err := libRepo.GetAll()
Expect(err).To(BeNil())
initialLibCount = len(existingLibs)
library1 = model.Library{ID: 0, Name: "Admin Test Library 1", Path: "/admin/test/path1"}
library2 = model.Library{ID: 0, Name: "Admin Test Library 2", Path: "/admin/test/path2"}
// Create test libraries
Expect(libRepo.Put(&library1)).To(BeNil())
Expect(libRepo.Put(&library2)).To(BeNil())
})
AfterEach(func() {
// Clean up test libraries and their associations
_ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}})
// Clean up user-library associations for these test libraries
_, _ = repo.(*userRepository).executeSQL(squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{library1.ID, library2.ID}}))
})
It("automatically assigns all libraries to admin users when created", func() {
adminUser := model.User{
ID: "admin-user-id-1",
UserName: "adminuser1",
Name: "Admin User",
Email: "admin1@example.com",
NewPassword: "password",
IsAdmin: true,
}
err := repo.Put(&adminUser)
Expect(err).To(BeNil())
// Admin should automatically have access to all libraries (including existing ones)
libraries, err := repo.GetUserLibraries(adminUser.ID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(initialLibCount + 2)) // Initial libraries + our 2 test libraries
libIDs := make([]int, len(libraries))
for i, lib := range libraries {
libIDs[i] = lib.ID
}
Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
})
It("automatically assigns all libraries to admin users when updated", func() {
// Create regular user first
regularUser := model.User{
ID: "regular-user-id-1",
UserName: "regularuser1",
Name: "Regular User",
Email: "regular1@example.com",
NewPassword: "password",
IsAdmin: false,
}
err := repo.Put(&regularUser)
Expect(err).To(BeNil())
// Give them access to just one library
err = repo.SetUserLibraries(regularUser.ID, []int{library1.ID})
Expect(err).To(BeNil())
// Promote to admin
regularUser.IsAdmin = true
err = repo.Put(&regularUser)
Expect(err).To(BeNil())
// Should now have access to all libraries (including existing ones)
libraries, err := repo.GetUserLibraries(regularUser.ID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(initialLibCount + 2)) // Initial libraries + our 2 test libraries
libIDs := make([]int, len(libraries))
for i, lib := range libraries {
libIDs[i] = lib.ID
}
// Should include our test libraries plus all existing ones
Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
})
It("assigns default libraries to regular users", func() {
regularUser := model.User{
ID: "regular-user-id-2",
UserName: "regularuser2",
Name: "Regular User",
Email: "regular2@example.com",
NewPassword: "password",
IsAdmin: false,
}
err := repo.Put(&regularUser)
Expect(err).To(BeNil())
// Regular user should be assigned to default libraries (library ID 1 from migration)
libraries, err := repo.GetUserLibraries(regularUser.ID)
Expect(err).To(BeNil())
Expect(libraries).To(HaveLen(1))
Expect(libraries[0].ID).To(Equal(1))
Expect(libraries[0].DefaultNewUsers).To(BeTrue())
})
})
Describe("Libraries Field Population", func() {
var (
libRepo model.LibraryRepository
library1 model.Library
library2 model.Library
testUser model.User
)
BeforeEach(func() {
libRepo = NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder())
library1 = model.Library{ID: 0, Name: "Field Test Library 1", Path: "/field/test/path1"}
library2 = model.Library{ID: 0, Name: "Field Test Library 2", Path: "/field/test/path2"}
// Create test libraries
Expect(libRepo.Put(&library1)).To(BeNil())
Expect(libRepo.Put(&library2)).To(BeNil())
// Create test user
testUser = model.User{
ID: "field-test-user",
UserName: "fieldtestuser",
Name: "Field Test User",
Email: "fieldtest@example.com",
NewPassword: "password",
IsAdmin: false,
}
Expect(repo.Put(&testUser)).To(BeNil())
// Assign libraries to user
Expect(repo.SetUserLibraries(testUser.ID, []int{library1.ID, library2.ID})).To(BeNil())
})
AfterEach(func() {
// Clean up test libraries and their associations
_ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}})
_ = repo.(*userRepository).delete(squirrel.Eq{"id": testUser.ID})
// Clean up user-library associations for these test libraries
_, _ = repo.(*userRepository).executeSQL(squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{library1.ID, library2.ID}}))
})
It("populates Libraries field when getting a single user", func() {
user, err := repo.Get(testUser.ID)
Expect(err).To(BeNil())
Expect(user.Libraries).To(HaveLen(2))
libIDs := []int{user.Libraries[0].ID, user.Libraries[1].ID}
Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
// Check that library details are properly populated
for _, lib := range user.Libraries {
if lib.ID == library1.ID {
Expect(lib.Name).To(Equal("Field Test Library 1"))
Expect(lib.Path).To(Equal("/field/test/path1"))
} else if lib.ID == library2.ID {
Expect(lib.Name).To(Equal("Field Test Library 2"))
Expect(lib.Path).To(Equal("/field/test/path2"))
}
}
})
It("populates Libraries field when getting all users", func() {
users, err := repo.(*userRepository).GetAll()
Expect(err).To(BeNil())
// Find our test user in the results
var foundUser *model.User
for i := range users {
if users[i].ID == testUser.ID {
foundUser = &users[i]
break
}
}
Expect(foundUser).ToNot(BeNil())
Expect(foundUser.Libraries).To(HaveLen(2))
libIDs := []int{foundUser.Libraries[0].ID, foundUser.Libraries[1].ID}
Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
})
It("populates Libraries field when finding user by username", func() {
user, err := repo.FindByUsername(testUser.UserName)
Expect(err).To(BeNil())
Expect(user.Libraries).To(HaveLen(2))
libIDs := []int{user.Libraries[0].ID, user.Libraries[1].ID}
Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
})
It("returns default Libraries array for new regular users", func() {
// Create a user with no explicit library associations - should get default libraries
userWithoutLibs := model.User{
ID: "no-libs-user",
UserName: "nolibsuser",
Name: "No Libs User",
Email: "nolibs@example.com",
NewPassword: "password",
IsAdmin: false,
}
Expect(repo.Put(&userWithoutLibs)).To(BeNil())
defer func() {
_ = repo.(*userRepository).delete(squirrel.Eq{"id": userWithoutLibs.ID})
}()
user, err := repo.Get(userWithoutLibs.ID)
Expect(err).To(BeNil())
Expect(user.Libraries).ToNot(BeNil())
// Regular users should be assigned to default libraries (library ID 1 from migration)
Expect(user.Libraries).To(HaveLen(1))
Expect(user.Libraries[0].ID).To(Equal(1))
})
})
}) })

View File

@@ -116,6 +116,24 @@ type controller struct {
changesDetected bool changesDetected bool
} }
// getLastScanTime returns the most recent scan time across all libraries
func (s *controller) getLastScanTime(ctx context.Context) (time.Time, error) {
libs, err := s.ds.Library(ctx).GetAll(model.QueryOptions{
Sort: "last_scan_at",
Order: "desc",
Max: 1,
})
if err != nil {
return time.Time{}, fmt.Errorf("getting libraries: %w", err)
}
if len(libs) == 0 {
return time.Time{}, nil
}
return libs[0].LastScanAt, nil
}
// getScanInfo retrieves scan status from the database // getScanInfo retrieves scan status from the database
func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed time.Duration, lastErr string) { func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed time.Duration, lastErr string) {
lastErr, _ = s.ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "") lastErr, _ = s.ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "")
@@ -128,10 +146,10 @@ func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed
if running.Load() { if running.Load() {
elapsed = time.Since(startTime) elapsed = time.Since(startTime)
} else { } else {
// If scan is not running, try to get the last scan time for the library // If scan is not running, calculate elapsed time using the most recent scan time
lib, err := s.ds.Library(ctx).Get(1) //TODO Multi-library lastScanTime, err := s.getLastScanTime(ctx)
if err == nil { if err == nil && !lastScanTime.IsZero() {
elapsed = lib.LastScanAt.Sub(startTime) elapsed = lastScanTime.Sub(startTime)
} }
} }
} }
@@ -141,9 +159,9 @@ func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed
} }
func (s *controller) Status(ctx context.Context) (*StatusInfo, error) { func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
lib, err := s.ds.Library(ctx).Get(1) //TODO Multi-library lastScanTime, err := s.getLastScanTime(ctx)
if err != nil { if err != nil {
return nil, fmt.Errorf("getting library: %w", err) return nil, fmt.Errorf("getting last scan time: %w", err)
} }
scanType, elapsed, lastErr := s.getScanInfo(ctx) scanType, elapsed, lastErr := s.getScanInfo(ctx)
@@ -151,7 +169,7 @@ func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
if running.Load() { if running.Load() {
status := &StatusInfo{ status := &StatusInfo{
Scanning: true, Scanning: true,
LastScan: lib.LastScanAt, LastScan: lastScanTime,
Count: s.count.Load(), Count: s.count.Load(),
FolderCount: s.folderCount.Load(), FolderCount: s.folderCount.Load(),
LastError: lastErr, LastError: lastErr,
@@ -167,7 +185,7 @@ func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
} }
return &StatusInfo{ return &StatusInfo{
Scanning: false, Scanning: false,
LastScan: lib.LastScanAt, LastScan: lastScanTime,
Count: uint32(count), Count: uint32(count),
FolderCount: uint32(folderCount), FolderCount: uint32(folderCount),
LastError: lastErr, LastError: lastErr,
@@ -198,7 +216,6 @@ func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]strin
// Prepare the context for the scan // Prepare the context for the scan
ctx := request.AddValues(s.rootCtx, requestCtx) ctx := request.AddValues(s.rootCtx, requestCtx)
ctx = events.BroadcastToAll(ctx)
ctx = auth.WithAdminUser(ctx, s.ds) ctx = auth.WithAdminUser(ctx, s.ds)
// Send the initial scan status event // Send the initial scan status event
@@ -218,7 +235,7 @@ func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]strin
// If changes were detected, send a refresh event to all clients // If changes were detected, send a refresh event to all clients
if s.changesDetected { if s.changesDetected {
log.Debug(ctx, "Library changes imported. Sending refresh event") log.Debug(ctx, "Library changes imported. Sending refresh event")
s.broker.SendMessage(ctx, &events.RefreshResource{}) s.broker.SendBroadcastMessage(ctx, &events.RefreshResource{})
} }
// Send the final scan status event, with totals // Send the final scan status event, with totals
if count, folderCount, err := s.getCounters(ctx); err != nil { if count, folderCount, err := s.getCounters(ctx); err != nil {
@@ -297,5 +314,5 @@ func (s *controller) trackProgress(ctx context.Context, progress <-chan *Progres
} }
func (s *controller) sendMessage(ctx context.Context, status *events.ScanStatus) { func (s *controller) sendMessage(ctx context.Context, status *events.ScanStatus) {
s.broker.SendMessage(ctx, status) s.broker.SendBroadcastMessage(ctx, status)
} }

View File

@@ -9,7 +9,6 @@ import (
"github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/events"
@@ -32,7 +31,6 @@ var _ = Describe("Controller", func() {
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
ds.MockedProperty = &tests.MockedPropertyRepo{} ds.MockedProperty = &tests.MockedPropertyRepo{}
ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), core.NewPlaylists(ds), metrics.NewNoopInstance()) ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), core.NewPlaylists(ds), metrics.NewNoopInstance())
Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Name: "lib", Path: "/tmp"})).To(Succeed())
}) })
It("includes last scan error", func() { It("includes last scan error", func() {

View File

@@ -28,6 +28,7 @@ import (
func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore, cw artwork.CacheWarmer, libs []model.Library) *phaseFolders { func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore, cw artwork.CacheWarmer, libs []model.Library) *phaseFolders {
var jobs []*scanJob var jobs []*scanJob
var updatedLibs []model.Library
for _, lib := range libs { for _, lib := range libs {
if lib.LastScanStartedAt.IsZero() { if lib.LastScanStartedAt.IsZero() {
err := ds.Library(ctx).ScanBegin(lib.ID, state.fullScan) err := ds.Library(ctx).ScanBegin(lib.ID, state.fullScan)
@@ -54,7 +55,12 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor
continue continue
} }
jobs = append(jobs, job) jobs = append(jobs, job)
updatedLibs = append(updatedLibs, lib)
} }
// Update the state with the libraries that have been processed and have their scan timestamps set
state.libraries = updatedLibs
return &phaseFolders{jobs: jobs, ctx: ctx, ds: ds, state: state} return &phaseFolders{jobs: jobs, ctx: ctx, ds: ds, state: state}
} }
@@ -336,7 +342,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
} }
// Save all tags to DB // Save all tags to DB
err = tagRepo.Add(entry.tags...) err = tagRepo.Add(entry.job.lib.ID, entry.tags...)
if err != nil { if err != nil {
log.Error(p.ctx, "Scanner: Error persisting tags to DB", "folder", entry.path, err) log.Error(p.ctx, "Scanner: Error persisting tags to DB", "folder", entry.path, err)
return err return err
@@ -418,12 +424,14 @@ func (p *phaseFolders) persistAlbum(repo model.AlbumRepository, a *model.Album,
if prevID == "" { if prevID == "" {
return nil return nil
} }
// Reassign annotation from previous album to new album // Reassign annotation from previous album to new album
log.Trace(p.ctx, "Reassigning album annotations", "from", prevID, "to", a.ID, "album", a.Name) log.Trace(p.ctx, "Reassigning album annotations", "from", prevID, "to", a.ID, "album", a.Name)
if err := repo.ReassignAnnotation(prevID, a.ID); err != nil { if err := repo.ReassignAnnotation(prevID, a.ID); err != nil {
log.Warn(p.ctx, "Scanner: Could not reassign annotations", "from", prevID, "to", a.ID, "album", a.Name, err) log.Warn(p.ctx, "Scanner: Could not reassign annotations", "from", prevID, "to", a.ID, "album", a.Name, err)
p.state.sendWarning(fmt.Sprintf("Could not reassign annotations from %s to %s ('%s'): %v", prevID, a.ID, a.Name, err)) p.state.sendWarning(fmt.Sprintf("Could not reassign annotations from %s to %s ('%s'): %v", prevID, a.ID, a.Name, err))
} }
// Keep created_at field from previous instance of the album // Keep created_at field from previous instance of the album
if err := repo.CopyAttributes(prevID, a.ID, "created_at"); err != nil { if err := repo.CopyAttributes(prevID, a.ID, "created_at"); err != nil {
// Silently ignore when the previous album is not found // Silently ignore when the previous album is not found

View File

@@ -3,6 +3,7 @@ package scanner
import ( import (
"context" "context"
"fmt" "fmt"
"sync"
"sync/atomic" "sync/atomic"
ppl "github.com/google/go-pipeline/pkg/pipeline" ppl "github.com/google/go-pipeline/pkg/pipeline"
@@ -31,14 +32,21 @@ type missingTracks struct {
// 4. Updates the database with the new locations of the matched files and removes the old entries. // 4. Updates the database with the new locations of the matched files and removes the old entries.
// 5. Logs the results and finalizes the phase by reporting the total number of matched files. // 5. Logs the results and finalizes the phase by reporting the total number of matched files.
type phaseMissingTracks struct { type phaseMissingTracks struct {
ctx context.Context ctx context.Context
ds model.DataStore ds model.DataStore
totalMatched atomic.Uint32 totalMatched atomic.Uint32
state *scanState state *scanState
processedAlbumAnnotations map[string]bool // Track processed album annotation reassignments
annotationMutex sync.RWMutex // Protects processedAlbumAnnotations
} }
func createPhaseMissingTracks(ctx context.Context, state *scanState, ds model.DataStore) *phaseMissingTracks { func createPhaseMissingTracks(ctx context.Context, state *scanState, ds model.DataStore) *phaseMissingTracks {
return &phaseMissingTracks{ctx: ctx, ds: ds, state: state} return &phaseMissingTracks{
ctx: ctx,
ds: ds,
state: state,
processedAlbumAnnotations: make(map[string]bool),
}
} }
func (p *phaseMissingTracks) description() string { func (p *phaseMissingTracks) description() string {
@@ -52,17 +60,15 @@ func (p *phaseMissingTracks) producer() ppl.Producer[*missingTracks] {
func (p *phaseMissingTracks) produce(put func(tracks *missingTracks)) error { func (p *phaseMissingTracks) produce(put func(tracks *missingTracks)) error {
count := 0 count := 0
var putIfMatched = func(mt missingTracks) { var putIfMatched = func(mt missingTracks) {
if mt.pid != "" && len(mt.matched) > 0 { if mt.pid != "" && len(mt.missing) > 0 {
log.Trace(p.ctx, "Scanner: Found missing and matching tracks", "pid", mt.pid, "missing", len(mt.missing), "matched", len(mt.matched), "lib", mt.lib.Name) log.Trace(p.ctx, "Scanner: Found missing tracks", "pid", mt.pid, "missing", "title", mt.missing[0].Title,
len(mt.missing), "matched", len(mt.matched), "lib", mt.lib.Name,
)
count++ count++
put(&mt) put(&mt)
} }
} }
libs, err := p.ds.Library(p.ctx).GetAll() for _, lib := range p.state.libraries {
if err != nil {
return fmt.Errorf("loading libraries: %w", err)
}
for _, lib := range libs {
if lib.LastScanStartedAt.IsZero() { if lib.LastScanStartedAt.IsZero() {
continue continue
} }
@@ -104,10 +110,13 @@ func (p *phaseMissingTracks) produce(put func(tracks *missingTracks)) error {
func (p *phaseMissingTracks) stages() []ppl.Stage[*missingTracks] { func (p *phaseMissingTracks) stages() []ppl.Stage[*missingTracks] {
return []ppl.Stage[*missingTracks]{ return []ppl.Stage[*missingTracks]{
ppl.NewStage(p.processMissingTracks, ppl.Name("process missing tracks")), ppl.NewStage(p.processMissingTracks, ppl.Name("process missing tracks")),
ppl.NewStage(p.processCrossLibraryMoves, ppl.Name("process cross-library moves")),
} }
} }
func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTracks, error) { func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTracks, error) {
hasMatches := false
for _, ms := range in.missing { for _, ms := range in.missing {
var exactMatch model.MediaFile var exactMatch model.MediaFile
var equivalentMatch model.MediaFile var equivalentMatch model.MediaFile
@@ -132,6 +141,7 @@ func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTr
return nil, err return nil, err
} }
p.totalMatched.Add(1) p.totalMatched.Add(1)
hasMatches = true
continue continue
} }
@@ -145,6 +155,7 @@ func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTr
return nil, err return nil, err
} }
p.totalMatched.Add(1) p.totalMatched.Add(1)
hasMatches = true
continue continue
} }
@@ -157,23 +168,141 @@ func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTr
return nil, err return nil, err
} }
p.totalMatched.Add(1) p.totalMatched.Add(1)
hasMatches = true
} }
} }
// If any matches were found in this missingTracks group, return nil
// This signals the next stage to skip processing this group
if hasMatches {
return nil, nil
}
// If no matches found, pass through to next stage
return in, nil return in, nil
} }
func (p *phaseMissingTracks) moveMatched(mt, ms model.MediaFile) error { // processCrossLibraryMoves processes files that weren't matched within their library
// and attempts to find matches in other libraries
func (p *phaseMissingTracks) processCrossLibraryMoves(in *missingTracks) (*missingTracks, error) {
// Skip if input is nil (meaning previous stage found matches)
if in == nil {
return nil, nil
}
log.Debug(p.ctx, "Scanner: Processing cross-library moves", "pid", in.pid, "missing", len(in.missing), "lib", in.lib.Name)
for _, missing := range in.missing {
found, err := p.findCrossLibraryMatch(missing)
if err != nil {
log.Error(p.ctx, "Scanner: Error searching for cross-library matches", "missing", missing.Path, "lib", in.lib.Name, err)
continue
}
if found.ID != "" {
log.Debug(p.ctx, "Scanner: Found cross-library moved track", "missing", missing.Path, "movedTo", found.Path, "fromLib", in.lib.Name, "toLib", found.LibraryName)
err := p.moveMatched(found, missing)
if err != nil {
log.Error(p.ctx, "Scanner: Error moving cross-library track", "missing", missing.Path, "movedTo", found.Path, err)
continue
}
p.totalMatched.Add(1)
}
}
return in, nil
}
// findCrossLibraryMatch searches for a missing file in other libraries using two-tier matching
func (p *phaseMissingTracks) findCrossLibraryMatch(missing model.MediaFile) (model.MediaFile, error) {
// First tier: Search by MusicBrainz Track ID if available
if missing.MbzReleaseTrackID != "" {
matches, err := p.ds.MediaFile(p.ctx).FindRecentFilesByMBZTrackID(missing, missing.CreatedAt)
if err != nil {
log.Error(p.ctx, "Scanner: Error searching for recent files by MBZ Track ID", "mbzTrackID", missing.MbzReleaseTrackID, err)
} else {
// Apply the same matching logic as within-library matching
for _, match := range matches {
if missing.Equals(match) {
return match, nil // Exact match found
}
}
// If only one match and it's equivalent, use it
if len(matches) == 1 && missing.IsEquivalent(matches[0]) {
return matches[0], nil
}
}
}
// Second tier: Search by intrinsic properties (title, size, suffix, etc.)
matches, err := p.ds.MediaFile(p.ctx).FindRecentFilesByProperties(missing, missing.CreatedAt)
if err != nil {
log.Error(p.ctx, "Scanner: Error searching for recent files by properties", "missing", missing.Path, err)
return model.MediaFile{}, err
}
// Apply the same matching logic as within-library matching
for _, match := range matches {
if missing.Equals(match) {
return match, nil // Exact match found
}
}
// If only one match and it's equivalent, use it
if len(matches) == 1 && missing.IsEquivalent(matches[0]) {
return matches[0], nil
}
return model.MediaFile{}, nil
}
func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error {
return p.ds.WithTx(func(tx model.DataStore) error { return p.ds.WithTx(func(tx model.DataStore) error {
discardedID := mt.ID discardedID := target.ID
mt.ID = ms.ID oldAlbumID := missing.AlbumID
err := tx.MediaFile(p.ctx).Put(&mt) newAlbumID := target.AlbumID
// Update the target media file with the missing file's ID. This effectively "moves" the track
// to the new location while keeping its annotations and references intact.
target.ID = missing.ID
err := tx.MediaFile(p.ctx).Put(&target)
if err != nil { if err != nil {
return fmt.Errorf("update matched track: %w", err) return fmt.Errorf("update matched track: %w", err)
} }
// Discard the new mediafile row (the one that was moved to)
err = tx.MediaFile(p.ctx).Delete(discardedID) err = tx.MediaFile(p.ctx).Delete(discardedID)
if err != nil { if err != nil {
return fmt.Errorf("delete discarded track: %w", err) return fmt.Errorf("delete discarded track: %w", err)
} }
// Handle album annotation reassignment if AlbumID changed
if oldAlbumID != newAlbumID {
// Use newAlbumID as key since we only care about avoiding duplicate reassignments to the same target
p.annotationMutex.RLock()
alreadyProcessed := p.processedAlbumAnnotations[newAlbumID]
p.annotationMutex.RUnlock()
if !alreadyProcessed {
p.annotationMutex.Lock()
// Double-check pattern to avoid race conditions
if !p.processedAlbumAnnotations[newAlbumID] {
// Reassign direct album annotations (starred, rating)
log.Debug(p.ctx, "Scanner: Reassigning album annotations", "from", oldAlbumID, "to", newAlbumID)
if err := tx.Album(p.ctx).ReassignAnnotation(oldAlbumID, newAlbumID); err != nil {
log.Warn(p.ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err)
}
// Note: RefreshPlayCounts will be called in later phases, so we don't need to call it here
p.processedAlbumAnnotations[newAlbumID] = true
}
p.annotationMutex.Unlock()
} else {
log.Trace(p.ctx, "Scanner: Skipping album annotation reassignment", "from", oldAlbumID, "to", newAlbumID)
}
}
p.state.changesDetected.Store(true) p.state.changesDetected.Store(true)
return nil return nil
}) })

View File

@@ -28,7 +28,9 @@ var _ = Describe("phaseMissingTracks", func() {
lr = &tests.MockLibraryRepo{} lr = &tests.MockLibraryRepo{}
lr.SetData(model.Libraries{{ID: 1, LastScanStartedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)}}) lr.SetData(model.Libraries{{ID: 1, LastScanStartedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)}})
ds = &tests.MockDataStore{MockedMediaFile: mr, MockedLibrary: lr} ds = &tests.MockDataStore{MockedMediaFile: mr, MockedLibrary: lr}
state = &scanState{} state = &scanState{
libraries: model.Libraries{{ID: 1, LastScanStartedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)}},
}
phase = createPhaseMissingTracks(ctx, state, ds) phase = createPhaseMissingTracks(ctx, state, ds)
}) })
@@ -68,12 +70,31 @@ var _ = Describe("phaseMissingTracks", func() {
err := phase.produce(put) err := phase.produce(put)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(produced).To(HaveLen(1)) Expect(produced).To(HaveLen(2))
Expect(produced[0].pid).To(Equal("A")) // PID A should have both missing and matched tracks
Expect(produced[0].missing).To(HaveLen(1)) var pidA *missingTracks
Expect(produced[0].matched).To(HaveLen(1)) for _, p := range produced {
if p.pid == "A" {
pidA = p
break
}
}
Expect(pidA).ToNot(BeNil())
Expect(pidA.missing).To(HaveLen(1))
Expect(pidA.matched).To(HaveLen(1))
// PID B should have only missing tracks
var pidB *missingTracks
for _, p := range produced {
if p.pid == "B" {
pidB = p
break
}
}
Expect(pidB).ToNot(BeNil())
Expect(pidB.missing).To(HaveLen(1))
Expect(pidB.matched).To(HaveLen(0))
}) })
It("should not call put if there are no matches for any missing tracks", func() { It("should call put for any missing tracks even without matches", func() {
mr.SetData(model.MediaFiles{ mr.SetData(model.MediaFiles{
{ID: "1", PID: "A", Missing: true, LibraryID: 1}, {ID: "1", PID: "A", Missing: true, LibraryID: 1},
{ID: "2", PID: "B", Missing: true, LibraryID: 1}, {ID: "2", PID: "B", Missing: true, LibraryID: 1},
@@ -82,7 +103,22 @@ var _ = Describe("phaseMissingTracks", func() {
err := phase.produce(put) err := phase.produce(put)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(produced).To(BeZero()) Expect(produced).To(HaveLen(2))
// Both PID A and PID B should be produced even without matches
var pidA, pidB *missingTracks
for _, p := range produced {
if p.pid == "A" {
pidA = p
} else if p.pid == "B" {
pidB = p
}
}
Expect(pidA).ToNot(BeNil())
Expect(pidA.missing).To(HaveLen(1))
Expect(pidA.matched).To(HaveLen(0))
Expect(pidB).ToNot(BeNil())
Expect(pidB.missing).To(HaveLen(1))
Expect(pidB.matched).To(HaveLen(0))
}) })
}) })
}) })
@@ -286,4 +322,448 @@ var _ = Describe("phaseMissingTracks", func() {
}) })
}) })
}) })
Describe("processCrossLibraryMoves", func() {
It("should skip processing if input is nil", func() {
result, err := phase.processCrossLibraryMoves(nil)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeNil())
})
It("should process cross-library moves using MusicBrainz Track ID", func() {
scanStartTime := time.Now().Add(-1 * time.Hour)
missingTrack := model.MediaFile{
ID: "missing1",
LibraryID: 1,
MbzReleaseTrackID: "mbz-track-123",
Title: "Test Track",
Size: 1000,
Suffix: "mp3",
Path: "/lib1/track.mp3",
Missing: true,
CreatedAt: scanStartTime.Add(-30 * time.Minute),
}
movedTrack := model.MediaFile{
ID: "moved1",
LibraryID: 2,
MbzReleaseTrackID: "mbz-track-123",
Title: "Test Track",
Size: 1000,
Suffix: "mp3",
Path: "/lib2/track.mp3",
Missing: false,
CreatedAt: scanStartTime.Add(-10 * time.Minute),
}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&movedTrack)
in := &missingTracks{
lib: model.Library{ID: 1, Name: "Library 1"},
missing: []model.MediaFile{missingTrack},
}
result, err := phase.processCrossLibraryMoves(in)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(Equal(in))
Expect(phase.totalMatched.Load()).To(Equal(uint32(1)))
Expect(state.changesDetected.Load()).To(BeTrue())
// Verify the move was performed
updatedTrack, _ := ds.MediaFile(ctx).Get("missing1")
Expect(updatedTrack.Path).To(Equal("/lib2/track.mp3"))
Expect(updatedTrack.LibraryID).To(Equal(2))
})
It("should fall back to intrinsic properties when MBZ Track ID is empty", func() {
scanStartTime := time.Now().Add(-1 * time.Hour)
missingTrack := model.MediaFile{
ID: "missing2",
LibraryID: 1,
MbzReleaseTrackID: "",
Title: "Test Track 2",
Size: 2000,
Suffix: "flac",
DiscNumber: 1,
TrackNumber: 1,
Album: "Test Album",
Path: "/lib1/track2.flac",
Missing: true,
CreatedAt: scanStartTime.Add(-30 * time.Minute),
}
movedTrack := model.MediaFile{
ID: "moved2",
LibraryID: 2,
MbzReleaseTrackID: "",
Title: "Test Track 2",
Size: 2000,
Suffix: "flac",
DiscNumber: 1,
TrackNumber: 1,
Album: "Test Album",
Path: "/lib2/track2.flac",
Missing: false,
CreatedAt: scanStartTime.Add(-10 * time.Minute),
}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&movedTrack)
in := &missingTracks{
lib: model.Library{ID: 1, Name: "Library 1"},
missing: []model.MediaFile{missingTrack},
}
result, err := phase.processCrossLibraryMoves(in)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(Equal(in))
Expect(phase.totalMatched.Load()).To(Equal(uint32(1)))
Expect(state.changesDetected.Load()).To(BeTrue())
// Verify the move was performed
updatedTrack, _ := ds.MediaFile(ctx).Get("missing2")
Expect(updatedTrack.Path).To(Equal("/lib2/track2.flac"))
Expect(updatedTrack.LibraryID).To(Equal(2))
})
It("should not match files in the same library", func() {
scanStartTime := time.Now().Add(-1 * time.Hour)
missingTrack := model.MediaFile{
ID: "missing3",
LibraryID: 1,
MbzReleaseTrackID: "mbz-track-456",
Title: "Test Track 3",
Size: 3000,
Suffix: "mp3",
Path: "/lib1/track3.mp3",
Missing: true,
CreatedAt: scanStartTime.Add(-30 * time.Minute),
}
sameLibTrack := model.MediaFile{
ID: "same1",
LibraryID: 1, // Same library
MbzReleaseTrackID: "mbz-track-456",
Title: "Test Track 3",
Size: 3000,
Suffix: "mp3",
Path: "/lib1/other/track3.mp3",
Missing: false,
CreatedAt: scanStartTime.Add(-10 * time.Minute),
}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&sameLibTrack)
in := &missingTracks{
lib: model.Library{ID: 1, Name: "Library 1"},
missing: []model.MediaFile{missingTrack},
}
result, err := phase.processCrossLibraryMoves(in)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(Equal(in))
Expect(phase.totalMatched.Load()).To(Equal(uint32(0)))
Expect(state.changesDetected.Load()).To(BeFalse())
})
It("should prioritize MBZ Track ID over intrinsic properties", func() {
scanStartTime := time.Now().Add(-1 * time.Hour)
missingTrack := model.MediaFile{
ID: "missing4",
LibraryID: 1,
MbzReleaseTrackID: "mbz-track-789",
Title: "Test Track 4",
Size: 4000,
Suffix: "mp3",
Path: "/lib1/track4.mp3",
Missing: true,
CreatedAt: scanStartTime.Add(-30 * time.Minute),
}
// Track with same MBZ ID
mbzTrack := model.MediaFile{
ID: "mbz1",
LibraryID: 2,
MbzReleaseTrackID: "mbz-track-789",
Title: "Test Track 4",
Size: 4000,
Suffix: "mp3",
Path: "/lib2/track4.mp3",
Missing: false,
CreatedAt: scanStartTime.Add(-10 * time.Minute),
}
// Track with same intrinsic properties but no MBZ ID
intrinsicTrack := model.MediaFile{
ID: "intrinsic1",
LibraryID: 3,
MbzReleaseTrackID: "",
Title: "Test Track 4",
Size: 4000,
Suffix: "mp3",
DiscNumber: 1,
TrackNumber: 1,
Album: "Test Album",
Path: "/lib3/track4.mp3",
Missing: false,
CreatedAt: scanStartTime.Add(-5 * time.Minute),
}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&mbzTrack)
_ = ds.MediaFile(ctx).Put(&intrinsicTrack)
in := &missingTracks{
lib: model.Library{ID: 1, Name: "Library 1"},
missing: []model.MediaFile{missingTrack},
}
result, err := phase.processCrossLibraryMoves(in)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(Equal(in))
Expect(phase.totalMatched.Load()).To(Equal(uint32(1)))
Expect(state.changesDetected.Load()).To(BeTrue())
// Verify the MBZ track was chosen (not the intrinsic one)
updatedTrack, _ := ds.MediaFile(ctx).Get("missing4")
Expect(updatedTrack.Path).To(Equal("/lib2/track4.mp3"))
Expect(updatedTrack.LibraryID).To(Equal(2))
})
It("should handle equivalent matches correctly", func() {
scanStartTime := time.Now().Add(-1 * time.Hour)
missingTrack := model.MediaFile{
ID: "missing5",
LibraryID: 1,
MbzReleaseTrackID: "",
Title: "Test Track 5",
Size: 5000,
Suffix: "mp3",
Path: "/lib1/path/track5.mp3",
Missing: true,
CreatedAt: scanStartTime.Add(-30 * time.Minute),
}
// Equivalent match (same filename, different directory)
equivalentTrack := model.MediaFile{
ID: "equiv1",
LibraryID: 2,
MbzReleaseTrackID: "",
Title: "Test Track 5",
Size: 5000,
Suffix: "mp3",
Path: "/lib2/different/track5.mp3",
Missing: false,
CreatedAt: scanStartTime.Add(-10 * time.Minute),
}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&equivalentTrack)
in := &missingTracks{
lib: model.Library{ID: 1, Name: "Library 1"},
missing: []model.MediaFile{missingTrack},
}
result, err := phase.processCrossLibraryMoves(in)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(Equal(in))
Expect(phase.totalMatched.Load()).To(Equal(uint32(1)))
Expect(state.changesDetected.Load()).To(BeTrue())
// Verify the equivalent match was accepted
updatedTrack, _ := ds.MediaFile(ctx).Get("missing5")
Expect(updatedTrack.Path).To(Equal("/lib2/different/track5.mp3"))
Expect(updatedTrack.LibraryID).To(Equal(2))
})
It("should skip matching when multiple matches are found but none are exact", func() {
scanStartTime := time.Now().Add(-1 * time.Hour)
missingTrack := model.MediaFile{
ID: "missing6",
LibraryID: 1,
MbzReleaseTrackID: "",
Title: "Test Track 6",
Size: 6000,
Suffix: "mp3",
DiscNumber: 1,
TrackNumber: 1,
Album: "Test Album",
Path: "/lib1/track6.mp3",
Missing: true,
CreatedAt: scanStartTime.Add(-30 * time.Minute),
}
// Multiple matches with different metadata (not exact matches)
match1 := model.MediaFile{
ID: "match1",
LibraryID: 2,
MbzReleaseTrackID: "",
Title: "Test Track 6",
Size: 6000,
Suffix: "mp3",
DiscNumber: 1,
TrackNumber: 1,
Album: "Test Album",
Path: "/lib2/different_track.mp3",
Artist: "Different Artist", // This makes it non-exact
Missing: false,
CreatedAt: scanStartTime.Add(-10 * time.Minute),
}
match2 := model.MediaFile{
ID: "match2",
LibraryID: 3,
MbzReleaseTrackID: "",
Title: "Test Track 6",
Size: 6000,
Suffix: "mp3",
DiscNumber: 1,
TrackNumber: 1,
Album: "Test Album",
Path: "/lib3/another_track.mp3",
Artist: "Another Artist", // This makes it non-exact
Missing: false,
CreatedAt: scanStartTime.Add(-5 * time.Minute),
}
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&match1)
_ = ds.MediaFile(ctx).Put(&match2)
in := &missingTracks{
lib: model.Library{ID: 1, Name: "Library 1"},
missing: []model.MediaFile{missingTrack},
}
result, err := phase.processCrossLibraryMoves(in)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(Equal(in))
Expect(phase.totalMatched.Load()).To(Equal(uint32(0)))
Expect(state.changesDetected.Load()).To(BeFalse())
// Verify no move was performed
unchangedTrack, _ := ds.MediaFile(ctx).Get("missing6")
Expect(unchangedTrack.Path).To(Equal("/lib1/track6.mp3"))
Expect(unchangedTrack.LibraryID).To(Equal(1))
})
It("should handle errors gracefully", func() {
// Set up mock to return error
mr.Err = true
missingTrack := model.MediaFile{
ID: "missing7",
LibraryID: 1,
MbzReleaseTrackID: "mbz-track-error",
Title: "Test Track 7",
Size: 7000,
Suffix: "mp3",
Path: "/lib1/track7.mp3",
Missing: true,
CreatedAt: time.Now().Add(-30 * time.Minute),
}
in := &missingTracks{
lib: model.Library{ID: 1, Name: "Library 1"},
missing: []model.MediaFile{missingTrack},
}
// Should not fail completely, just skip the problematic file
result, err := phase.processCrossLibraryMoves(in)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(Equal(in))
Expect(phase.totalMatched.Load()).To(Equal(uint32(0)))
Expect(state.changesDetected.Load()).To(BeFalse())
})
})
Describe("Album Annotation Reassignment", func() {
var (
albumRepo *tests.MockAlbumRepo
missingTrack model.MediaFile
matchedTrack model.MediaFile
oldAlbumID string
newAlbumID string
)
BeforeEach(func() {
albumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
albumRepo.ReassignAnnotationCalls = make(map[string]string)
oldAlbumID = "old-album-id"
newAlbumID = "new-album-id"
missingTrack = model.MediaFile{
ID: "missing-track-id",
PID: "same-pid",
Path: "old/path.mp3",
AlbumID: oldAlbumID,
LibraryID: 1,
Missing: true,
Annotations: model.Annotations{
PlayCount: 5,
Rating: 4,
Starred: true,
},
}
matchedTrack = model.MediaFile{
ID: "matched-track-id",
PID: "same-pid",
Path: "new/path.mp3",
AlbumID: newAlbumID,
LibraryID: 2, // Different library
Missing: false,
Annotations: model.Annotations{
PlayCount: 2,
Rating: 3,
Starred: false,
},
}
// Store both tracks in the database
_ = ds.MediaFile(ctx).Put(&missingTrack)
_ = ds.MediaFile(ctx).Put(&matchedTrack)
})
When("album ID changes during cross-library move", func() {
It("should reassign album annotations when AlbumID changes", func() {
err := phase.moveMatched(matchedTrack, missingTrack)
Expect(err).ToNot(HaveOccurred())
// Verify that ReassignAnnotation was called
Expect(albumRepo.ReassignAnnotationCalls).To(HaveKeyWithValue(oldAlbumID, newAlbumID))
})
It("should not reassign annotations when AlbumID is the same", func() {
missingTrack.AlbumID = newAlbumID // Same album
err := phase.moveMatched(matchedTrack, missingTrack)
Expect(err).ToNot(HaveOccurred())
// Verify that ReassignAnnotation was NOT called
Expect(albumRepo.ReassignAnnotationCalls).To(BeEmpty())
})
})
When("error handling", func() {
It("should handle ReassignAnnotation errors gracefully", func() {
// Make the album repo return an error
albumRepo.SetError(true)
// The move should still succeed even if annotation reassignment fails
err := phase.moveMatched(matchedTrack, missingTrack)
Expect(err).ToNot(HaveOccurred())
// Verify that the track was still moved (ID should be updated)
movedTrack, err := ds.MediaFile(ctx).Get(missingTrack.ID)
Expect(err).ToNot(HaveOccurred())
Expect(movedTrack.Path).To(Equal(matchedTrack.Path))
})
})
})
}) })

View File

@@ -28,6 +28,7 @@ type scanState struct {
progress chan<- *ProgressInfo progress chan<- *ProgressInfo
fullScan bool fullScan bool
changesDetected atomic.Bool changesDetected atomic.Bool
libraries model.Libraries // Store libraries list for consistency across phases
} }
func (s *scanState) sendProgress(info *ProgressInfo) { func (s *scanState) sendProgress(info *ProgressInfo) {
@@ -63,6 +64,7 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
state.sendWarning(fmt.Sprintf("getting libraries: %s", err)) state.sendWarning(fmt.Sprintf("getting libraries: %s", err))
return return
} }
state.libraries = libs
log.Info(ctx, "Scanner: Starting scan", "fullScan", state.fullScan, "numLibraries", len(libs)) log.Info(ctx, "Scanner: Starting scan", "fullScan", state.fullScan, "numLibraries", len(libs))
@@ -111,7 +113,7 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
s.runRefreshStats(ctx, &state), s.runRefreshStats(ctx, &state),
// Update last_scan_completed_at for all libraries // Update last_scan_completed_at for all libraries
s.runUpdateLibraries(ctx, libs, &state), s.runUpdateLibraries(ctx, &state),
// Optimize DB // Optimize DB
s.runOptimize(ctx), s.runOptimize(ctx),
@@ -186,11 +188,11 @@ func (s *scannerImpl) runOptimize(ctx context.Context) func() error {
} }
} }
func (s *scannerImpl) runUpdateLibraries(ctx context.Context, libs model.Libraries, state *scanState) func() error { func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState) func() error {
return func() error { return func() error {
start := time.Now() start := time.Now()
return s.ds.WithTx(func(tx model.DataStore) error { return s.ds.WithTx(func(tx model.DataStore) error {
for _, lib := range libs { for _, lib := range state.libraries {
err := tx.Library(ctx).ScanEnd(lib.ID) err := tx.Library(ctx).ScanEnd(lib.ID)
if err != nil { if err != nil {
log.Error(ctx, "Scanner: Error updating last scan completed", "lib", lib.Name, err) log.Error(ctx, "Scanner: Error updating last scan completed", "lib", lib.Name, err)
@@ -216,7 +218,7 @@ func (s *scannerImpl) runUpdateLibraries(ctx context.Context, libs model.Librari
log.Debug(ctx, "Scanner: No changes detected, skipping library stats refresh", "lib", lib.Name) log.Debug(ctx, "Scanner: No changes detected, skipping library stats refresh", "lib", lib.Name)
} }
} }
log.Debug(ctx, "Scanner: Updated libraries after scan", "elapsed", time.Since(start), "numLibraries", len(libs)) log.Debug(ctx, "Scanner: Updated libraries after scan", "elapsed", time.Since(start), "numLibraries", len(state.libraries))
return nil return nil
}, "scanner: update libraries") }, "scanner: update libraries")
} }

View File

@@ -0,0 +1,831 @@
package scanner_test
import (
"context"
"errors"
"path/filepath"
"testing/fstest"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/slice"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Scanner - Multi-Library", Ordered, func() {
var ctx context.Context
var lib1, lib2 model.Library
var ds *tests.MockDataStore
var s scanner.Scanner
createFS := func(path string, files fstest.MapFS) storagetest.FakeFS {
fs := storagetest.FakeFS{}
fs.SetFiles(files)
storagetest.Register(path, &fs)
return fs
}
BeforeAll(func() {
ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true})
tmpDir := GinkgoT().TempDir()
conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner-multilibrary.db?_journal_mode=WAL")
log.Warn("Using DB at " + conf.Server.DbPath)
db.Db().SetMaxOpenConns(1)
})
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DevExternalScanner = false
db.Init(ctx)
DeferCleanup(func() {
Expect(tests.ClearDB()).To(Succeed())
})
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
// Create the admin user in the database to match the context
adminUser := model.User{
ID: "123",
UserName: "admin",
Name: "Admin User",
IsAdmin: true,
NewPassword: "password",
}
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
core.NewPlaylists(ds), metrics.NewNoopInstance())
// Create two test libraries (let DB auto-assign IDs)
lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"}
lib2 = model.Library{Name: "Jazz Collection", Path: "jazz:///music"}
Expect(ds.Library(ctx).Put(&lib1)).To(Succeed())
Expect(ds.Library(ctx).Put(&lib2)).To(Succeed())
})
runScanner := func(ctx context.Context, fullScan bool) error {
_, err := s.ScanAll(ctx, fullScan)
return err
}
Context("Two Libraries with Different Content", func() {
BeforeEach(func() {
// Rock library content
beatles := template(_t{"albumartist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"})
zeppelin := template(_t{"albumartist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"})
_ = createFS("rock", fstest.MapFS{
"The Beatles/Abbey Road/01 - Come Together.mp3": beatles(track(1, "Come Together")),
"The Beatles/Abbey Road/02 - Something.mp3": beatles(track(2, "Something")),
"Led Zeppelin/IV/01 - Black Dog.mp3": zeppelin(track(1, "Black Dog")),
"Led Zeppelin/IV/02 - Rock and Roll.mp3": zeppelin(track(2, "Rock and Roll")),
})
// Jazz library content
miles := template(_t{"albumartist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"})
coltrane := template(_t{"albumartist": "John Coltrane", "album": "Giant Steps", "year": 1960, "genre": "Jazz"})
_ = createFS("jazz", fstest.MapFS{
"Miles Davis/Kind of Blue/01 - So What.mp3": miles(track(1, "So What")),
"Miles Davis/Kind of Blue/02 - Freddie Freeloader.mp3": miles(track(2, "Freddie Freeloader")),
"John Coltrane/Giant Steps/01 - Giant Steps.mp3": coltrane(track(1, "Giant Steps")),
"John Coltrane/Giant Steps/02 - Cousin Mary.mp3": coltrane(track(2, "Cousin Mary")),
})
})
When("scanning both libraries", func() {
It("should import files with correct library_id", func() {
Expect(runScanner(ctx, true)).To(Succeed())
// Check Rock library media files
rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib1.ID},
Sort: "title",
})
Expect(err).ToNot(HaveOccurred())
Expect(rockFiles).To(HaveLen(4))
rockTitles := slice.Map(rockFiles, func(f model.MediaFile) string { return f.Title })
Expect(rockTitles).To(ContainElements("Come Together", "Something", "Black Dog", "Rock and Roll"))
// Verify all rock files have correct library_id
for _, mf := range rockFiles {
Expect(mf.LibraryID).To(Equal(lib1.ID), "Rock file %s should have library_id %d", mf.Title, lib1.ID)
}
// Check Jazz library media files
jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib2.ID},
Sort: "title",
})
Expect(err).ToNot(HaveOccurred())
Expect(jazzFiles).To(HaveLen(4))
jazzTitles := slice.Map(jazzFiles, func(f model.MediaFile) string { return f.Title })
Expect(jazzTitles).To(ContainElements("So What", "Freddie Freeloader", "Giant Steps", "Cousin Mary"))
// Verify all jazz files have correct library_id
for _, mf := range jazzFiles {
Expect(mf.LibraryID).To(Equal(lib2.ID), "Jazz file %s should have library_id %d", mf.Title, lib2.ID)
}
})
It("should create albums with correct library_id", func() {
Expect(runScanner(ctx, true)).To(Succeed())
// Check Rock library albums
rockAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib1.ID},
Sort: "name",
})
Expect(err).ToNot(HaveOccurred())
Expect(rockAlbums).To(HaveLen(2))
Expect(rockAlbums[0].Name).To(Equal("Abbey Road"))
Expect(rockAlbums[0].LibraryID).To(Equal(lib1.ID))
Expect(rockAlbums[0].SongCount).To(Equal(2))
Expect(rockAlbums[1].Name).To(Equal("IV"))
Expect(rockAlbums[1].LibraryID).To(Equal(lib1.ID))
Expect(rockAlbums[1].SongCount).To(Equal(2))
// Check Jazz library albums
jazzAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib2.ID},
Sort: "name",
})
Expect(err).ToNot(HaveOccurred())
Expect(jazzAlbums).To(HaveLen(2))
Expect(jazzAlbums[0].Name).To(Equal("Giant Steps"))
Expect(jazzAlbums[0].LibraryID).To(Equal(lib2.ID))
Expect(jazzAlbums[0].SongCount).To(Equal(2))
Expect(jazzAlbums[1].Name).To(Equal("Kind of Blue"))
Expect(jazzAlbums[1].LibraryID).To(Equal(lib2.ID))
Expect(jazzAlbums[1].SongCount).To(Equal(2))
})
It("should create folders with correct library_id", func() {
Expect(runScanner(ctx, true)).To(Succeed())
// Check Rock library folders
rockFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib1.ID},
})
Expect(err).ToNot(HaveOccurred())
Expect(rockFolders).To(HaveLen(5)) // ., The Beatles, Led Zeppelin, Abbey Road, IV
for _, folder := range rockFolders {
Expect(folder.LibraryID).To(Equal(lib1.ID), "Rock folder %s should have library_id %d", folder.Name, lib1.ID)
}
// Check Jazz library folders
jazzFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib2.ID},
})
Expect(err).ToNot(HaveOccurred())
Expect(jazzFolders).To(HaveLen(5)) // ., Miles Davis, John Coltrane, Kind of Blue, Giant Steps
for _, folder := range jazzFolders {
Expect(folder.LibraryID).To(Equal(lib2.ID), "Jazz folder %s should have library_id %d", folder.Name, lib2.ID)
}
})
It("should create library-artist associations correctly", func() {
Expect(runScanner(ctx, true)).To(Succeed())
// Check library-artist associations
// Get all artists and check library associations
allArtists, err := ds.Artist(ctx).GetAll()
Expect(err).ToNot(HaveOccurred())
rockArtistNames := []string{}
jazzArtistNames := []string{}
for _, artist := range allArtists {
// Check if artist is associated with rock library
var count int64
err := db.Db().QueryRow(
"SELECT COUNT(*) FROM library_artist WHERE library_id = ? AND artist_id = ?",
lib1.ID, artist.ID,
).Scan(&count)
Expect(err).ToNot(HaveOccurred())
if count > 0 {
rockArtistNames = append(rockArtistNames, artist.Name)
}
// Check if artist is associated with jazz library
err = db.Db().QueryRow(
"SELECT COUNT(*) FROM library_artist WHERE library_id = ? AND artist_id = ?",
lib2.ID, artist.ID,
).Scan(&count)
Expect(err).ToNot(HaveOccurred())
if count > 0 {
jazzArtistNames = append(jazzArtistNames, artist.Name)
}
}
Expect(rockArtistNames).To(ContainElements("The Beatles", "Led Zeppelin"))
Expect(jazzArtistNames).To(ContainElements("Miles Davis", "John Coltrane"))
// Artists should not be shared between libraries (except [Unknown Artist])
for _, name := range rockArtistNames {
if name != "[Unknown Artist]" {
Expect(jazzArtistNames).ToNot(ContainElement(name))
}
}
})
It("should update library statistics correctly", func() {
Expect(runScanner(ctx, true)).To(Succeed())
// Check Rock library stats
rockLib, err := ds.Library(ctx).Get(lib1.ID)
Expect(err).ToNot(HaveOccurred())
Expect(rockLib.TotalSongs).To(Equal(4))
Expect(rockLib.TotalAlbums).To(Equal(2))
Expect(rockLib.TotalArtists).To(Equal(3)) // The Beatles, Led Zeppelin, [Unknown Artist]
Expect(rockLib.TotalFolders).To(Equal(2)) // Abbey Road, IV (only folders with audio files)
// Check Jazz library stats
jazzLib, err := ds.Library(ctx).Get(lib2.ID)
Expect(err).ToNot(HaveOccurred())
Expect(jazzLib.TotalSongs).To(Equal(4))
Expect(jazzLib.TotalAlbums).To(Equal(2))
Expect(jazzLib.TotalArtists).To(Equal(3)) // Miles Davis, John Coltrane, [Unknown Artist]
Expect(jazzLib.TotalFolders).To(Equal(2)) // Kind of Blue, Giant Steps (only folders with audio files)
})
})
When("libraries have different content", func() {
It("should maintain separate statistics per library", func() {
Expect(runScanner(ctx, true)).To(Succeed())
// Verify rock library stats
rockLib, err := ds.Library(ctx).Get(lib1.ID)
Expect(err).ToNot(HaveOccurred())
Expect(rockLib.TotalSongs).To(Equal(4))
Expect(rockLib.TotalAlbums).To(Equal(2))
// Verify jazz library stats
jazzLib, err := ds.Library(ctx).Get(lib2.ID)
Expect(err).ToNot(HaveOccurred())
Expect(jazzLib.TotalSongs).To(Equal(4))
Expect(jazzLib.TotalAlbums).To(Equal(2))
// Verify that libraries don't interfere with each other
rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib1.ID},
})
Expect(err).ToNot(HaveOccurred())
Expect(rockFiles).To(HaveLen(4))
jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib2.ID},
})
Expect(err).ToNot(HaveOccurred())
Expect(jazzFiles).To(HaveLen(4))
})
})
When("verifying library isolation", func() {
It("should keep library data completely separate", func() {
Expect(runScanner(ctx, true)).To(Succeed())
// Verify that rock library only contains rock content
rockAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib1.ID},
})
Expect(err).ToNot(HaveOccurred())
rockAlbumNames := slice.Map(rockAlbums, func(a model.Album) string { return a.Name })
Expect(rockAlbumNames).To(ContainElements("Abbey Road", "IV"))
Expect(rockAlbumNames).ToNot(ContainElements("Kind of Blue", "Giant Steps"))
// Verify that jazz library only contains jazz content
jazzAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib2.ID},
})
Expect(err).ToNot(HaveOccurred())
jazzAlbumNames := slice.Map(jazzAlbums, func(a model.Album) string { return a.Name })
Expect(jazzAlbumNames).To(ContainElements("Kind of Blue", "Giant Steps"))
Expect(jazzAlbumNames).ToNot(ContainElements("Abbey Road", "IV"))
})
})
When("same artist appears in different libraries", func() {
It("should associate artist with both libraries correctly", func() {
// Create libraries with Jeff Beck albums in both
jeffRock := template(_t{"albumartist": "Jeff Beck", "album": "Truth", "year": 1968, "genre": "Rock"})
jeffJazz := template(_t{"albumartist": "Jeff Beck", "album": "Blow by Blow", "year": 1975, "genre": "Jazz"})
beatles := template(_t{"albumartist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"})
miles := template(_t{"albumartist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"})
// Create rock library with Jeff Beck's Truth album
_ = createFS("rock", fstest.MapFS{
"The Beatles/Abbey Road/01 - Come Together.mp3": beatles(track(1, "Come Together")),
"The Beatles/Abbey Road/02 - Something.mp3": beatles(track(2, "Something")),
"Jeff Beck/Truth/01 - Beck's Bolero.mp3": jeffRock(track(1, "Beck's Bolero")),
"Jeff Beck/Truth/02 - Ol' Man River.mp3": jeffRock(track(2, "Ol' Man River")),
})
// Create jazz library with Jeff Beck's Blow by Blow album
_ = createFS("jazz", fstest.MapFS{
"Miles Davis/Kind of Blue/01 - So What.mp3": miles(track(1, "So What")),
"Miles Davis/Kind of Blue/02 - Freddie Freeloader.mp3": miles(track(2, "Freddie Freeloader")),
"Jeff Beck/Blow by Blow/01 - You Know What I Mean.mp3": jeffJazz(track(1, "You Know What I Mean")),
"Jeff Beck/Blow by Blow/02 - She's a Woman.mp3": jeffJazz(track(2, "She's a Woman")),
})
Expect(runScanner(ctx, true)).To(Succeed())
// Jeff Beck should be associated with both libraries
var rockCount, jazzCount int64
// Get Jeff Beck artist ID
jeffArtists, err := ds.Artist(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"name": "Jeff Beck"},
})
Expect(err).ToNot(HaveOccurred())
Expect(jeffArtists).To(HaveLen(1))
jeffID := jeffArtists[0].ID
// Check rock library association
err = db.Db().QueryRow(
"SELECT COUNT(*) FROM library_artist WHERE library_id = ? AND artist_id = ?",
lib1.ID, jeffID,
).Scan(&rockCount)
Expect(err).ToNot(HaveOccurred())
Expect(rockCount).To(Equal(int64(1)))
// Check jazz library association
err = db.Db().QueryRow(
"SELECT COUNT(*) FROM library_artist WHERE library_id = ? AND artist_id = ?",
lib2.ID, jeffID,
).Scan(&jazzCount)
Expect(err).ToNot(HaveOccurred())
Expect(jazzCount).To(Equal(int64(1)))
// Verify Jeff Beck albums are in correct libraries
rockAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib1.ID, "album_artist": "Jeff Beck"},
})
Expect(err).ToNot(HaveOccurred())
Expect(rockAlbums).To(HaveLen(1))
Expect(rockAlbums[0].Name).To(Equal("Truth"))
jazzAlbums, err := ds.Album(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib2.ID, "album_artist": "Jeff Beck"},
})
Expect(err).ToNot(HaveOccurred())
Expect(jazzAlbums).To(HaveLen(1))
Expect(jazzAlbums[0].Name).To(Equal("Blow by Blow"))
})
})
})
Context("Incremental Scan Behavior", func() {
BeforeEach(func() {
// Start with minimal content in both libraries
rock := template(_t{"albumartist": "Queen", "album": "News of the World", "year": 1977, "genre": "Rock"})
jazz := template(_t{"albumartist": "Bill Evans", "album": "Waltz for Debby", "year": 1961, "genre": "Jazz"})
createFS("rock", fstest.MapFS{
"Queen/News of the World/01 - We Will Rock You.mp3": rock(track(1, "We Will Rock You")),
})
createFS("jazz", fstest.MapFS{
"Bill Evans/Waltz for Debby/01 - My Foolish Heart.mp3": jazz(track(1, "My Foolish Heart")),
})
})
It("should handle incremental scans per library correctly", func() {
// Initial full scan
Expect(runScanner(ctx, true)).To(Succeed())
// Verify initial state
rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib1.ID},
})
Expect(err).ToNot(HaveOccurred())
Expect(rockFiles).To(HaveLen(1))
jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib2.ID},
})
Expect(err).ToNot(HaveOccurred())
Expect(jazzFiles).To(HaveLen(1))
// Incremental scan should not duplicate existing files
Expect(runScanner(ctx, false)).To(Succeed())
// Verify counts remain the same
rockFiles, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib1.ID},
})
Expect(err).ToNot(HaveOccurred())
Expect(rockFiles).To(HaveLen(1))
jazzFiles, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib2.ID},
})
Expect(err).ToNot(HaveOccurred())
Expect(jazzFiles).To(HaveLen(1))
})
})
Context("Missing Files Handling", func() {
var rockFS storagetest.FakeFS
BeforeEach(func() {
rock := template(_t{"albumartist": "AC/DC", "album": "Back in Black", "year": 1980, "genre": "Rock"})
rockFS = createFS("rock", fstest.MapFS{
"AC-DC/Back in Black/01 - Hells Bells.mp3": rock(track(1, "Hells Bells")),
"AC-DC/Back in Black/02 - Shoot to Thrill.mp3": rock(track(2, "Shoot to Thrill")),
})
createFS("jazz", fstest.MapFS{
"Herbie Hancock/Head Hunters/01 - Chameleon.mp3": template(_t{
"albumartist": "Herbie Hancock", "album": "Head Hunters", "year": 1973, "genre": "Jazz",
})(track(1, "Chameleon")),
})
})
It("should mark missing files correctly per library", func() {
// Initial scan
Expect(runScanner(ctx, true)).To(Succeed())
// Remove one file from rock library only
rockFS.Remove("AC-DC/Back in Black/02 - Shoot to Thrill.mp3")
// Rescan
Expect(runScanner(ctx, false)).To(Succeed())
// Check that only the rock library file is marked as missing
missingRockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"library_id": lib1.ID},
squirrel.Eq{"missing": true},
},
})
Expect(err).ToNot(HaveOccurred())
Expect(missingRockFiles).To(HaveLen(1))
Expect(missingRockFiles[0].Title).To(Equal("Shoot to Thrill"))
// Check that jazz library files are not affected
missingJazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"library_id": lib2.ID},
squirrel.Eq{"missing": true},
},
})
Expect(err).ToNot(HaveOccurred())
Expect(missingJazzFiles).To(HaveLen(0))
// Verify non-missing files
presentRockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"library_id": lib1.ID},
squirrel.Eq{"missing": false},
},
})
Expect(err).ToNot(HaveOccurred())
Expect(presentRockFiles).To(HaveLen(1))
Expect(presentRockFiles[0].Title).To(Equal("Hells Bells"))
})
})
Context("Error Handling - Multi-Library", func() {
Context("Filesystem errors affecting one library", func() {
var rockFS storagetest.FakeFS
BeforeEach(func() {
// Set up content for both libraries
rock := template(_t{"albumartist": "AC/DC", "album": "Back in Black", "year": 1980, "genre": "Rock"})
jazz := template(_t{"albumartist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"})
rockFS = createFS("rock", fstest.MapFS{
"AC-DC/Back in Black/01 - Hells Bells.mp3": rock(track(1, "Hells Bells")),
"AC-DC/Back in Black/02 - Shoot to Thrill.mp3": rock(track(2, "Shoot to Thrill")),
})
createFS("jazz", fstest.MapFS{
"Miles Davis/Kind of Blue/01 - So What.mp3": jazz(track(1, "So What")),
"Miles Davis/Kind of Blue/02 - Freddie Freeloader.mp3": jazz(track(2, "Freddie Freeloader")),
})
})
It("should not affect scanning of other libraries", func() {
// Inject filesystem read error in rock library only
rockFS.SetError("AC-DC/Back in Black/01 - Hells Bells.mp3", errors.New("filesystem read error"))
// Scan should succeed overall and return warnings
warnings, err := s.ScanAll(ctx, true)
Expect(err).ToNot(HaveOccurred())
Expect(warnings).ToNot(BeEmpty(), "Should have warnings for filesystem errors")
// Jazz library should have been scanned successfully
jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib2.ID},
})
Expect(err).ToNot(HaveOccurred())
Expect(jazzFiles).To(HaveLen(2))
Expect(jazzFiles[0].Title).To(BeElementOf("So What", "Freddie Freeloader"))
Expect(jazzFiles[1].Title).To(BeElementOf("So What", "Freddie Freeloader"))
// Rock library may have partial content (depending on scanner implementation)
rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib1.ID},
})
Expect(err).ToNot(HaveOccurred())
// No specific expectation - some files may have been imported despite errors
_ = rockFiles
// Verify jazz library stats are correct
jazzLib, err := ds.Library(ctx).Get(lib2.ID)
Expect(err).ToNot(HaveOccurred())
Expect(jazzLib.TotalSongs).To(Equal(2))
// Error should be empty (warnings don't count as scan errors)
lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset")
Expect(err).ToNot(HaveOccurred())
Expect(lastError).To(BeEmpty())
})
It("should continue with warnings for affected library", func() {
// Inject read errors on multiple files in rock library
rockFS.SetError("AC-DC/Back in Black/01 - Hells Bells.mp3", errors.New("read error 1"))
rockFS.SetError("AC-DC/Back in Black/02 - Shoot to Thrill.mp3", errors.New("read error 2"))
// Scan should complete with warnings for multiple filesystem errors
warnings, err := s.ScanAll(ctx, true)
Expect(err).ToNot(HaveOccurred())
Expect(warnings).ToNot(BeEmpty(), "Should have warnings for multiple filesystem errors")
// Jazz library should be completely unaffected
jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib2.ID},
})
Expect(err).ToNot(HaveOccurred())
Expect(jazzFiles).To(HaveLen(2))
// Jazz library statistics should be accurate
jazzLib, err := ds.Library(ctx).Get(lib2.ID)
Expect(err).ToNot(HaveOccurred())
Expect(jazzLib.TotalSongs).To(Equal(2))
Expect(jazzLib.TotalAlbums).To(Equal(1))
// Error should be empty (warnings don't count as scan errors)
lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset")
Expect(err).ToNot(HaveOccurred())
Expect(lastError).To(BeEmpty())
})
})
Context("Database errors during multi-library scanning", func() {
BeforeEach(func() {
// Set up content for both libraries
rock := template(_t{"albumartist": "Queen", "album": "News of the World", "year": 1977, "genre": "Rock"})
jazz := template(_t{"albumartist": "Bill Evans", "album": "Waltz for Debby", "year": 1961, "genre": "Jazz"})
createFS("rock", fstest.MapFS{
"Queen/News of the World/01 - We Will Rock You.mp3": rock(track(1, "We Will Rock You")),
})
createFS("jazz", fstest.MapFS{
"Bill Evans/Waltz for Debby/01 - My Foolish Heart.mp3": jazz(track(1, "My Foolish Heart")),
})
})
It("should propagate database errors and stop scanning", func() {
// Install mock repo that injects DB error
mfRepo := &mockMediaFileRepo{
MediaFileRepository: ds.RealDS.MediaFile(ctx),
GetMissingAndMatchingError: errors.New("database connection failed"),
}
ds.MockedMediaFile = mfRepo
// Scan should return the database error
Expect(runScanner(ctx, false)).To(MatchError(ContainSubstring("database connection failed")))
// Error should be recorded in scanner properties
lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "")
Expect(err).ToNot(HaveOccurred())
Expect(lastError).To(ContainSubstring("database connection failed"))
})
It("should preserve error information in scanner properties", func() {
// Install mock repo that injects DB error
mfRepo := &mockMediaFileRepo{
MediaFileRepository: ds.RealDS.MediaFile(ctx),
GetMissingAndMatchingError: errors.New("critical database error"),
}
ds.MockedMediaFile = mfRepo
// Attempt scan (should fail)
Expect(runScanner(ctx, false)).To(HaveOccurred())
// Check that error is recorded in scanner properties
lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "")
Expect(err).ToNot(HaveOccurred())
Expect(lastError).To(ContainSubstring("critical database error"))
// Scan type should still be recorded
scanType, _ := ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "")
Expect(scanType).To(BeElementOf("incremental", "quick"))
})
})
Context("Mixed error scenarios", func() {
var rockFS storagetest.FakeFS
BeforeEach(func() {
// Set up rock library with filesystem that can error
rock := template(_t{"albumartist": "Metallica", "album": "Master of Puppets", "year": 1986, "genre": "Metal"})
rockFS = createFS("rock", fstest.MapFS{
"Metallica/Master of Puppets/01 - Battery.mp3": rock(track(1, "Battery")),
"Metallica/Master of Puppets/02 - Master of Puppets.mp3": rock(track(2, "Master of Puppets")),
})
// Set up jazz library normally
jazz := template(_t{"albumartist": "Herbie Hancock", "album": "Head Hunters", "year": 1973, "genre": "Jazz"})
createFS("jazz", fstest.MapFS{
"Herbie Hancock/Head Hunters/01 - Chameleon.mp3": jazz(track(1, "Chameleon")),
})
})
It("should handle filesystem errors in one library while other succeeds", func() {
// Inject filesystem error in rock library
rockFS.SetError("Metallica/Master of Puppets/01 - Battery.mp3", errors.New("disk read error"))
// Scan should complete with warnings (not hard error)
warnings, err := s.ScanAll(ctx, true)
Expect(err).ToNot(HaveOccurred())
Expect(warnings).ToNot(BeEmpty(), "Should have warnings for filesystem error")
// Jazz library should scan completely successfully
jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib2.ID},
})
Expect(err).ToNot(HaveOccurred())
Expect(jazzFiles).To(HaveLen(1))
Expect(jazzFiles[0].Title).To(Equal("Chameleon"))
// Jazz library statistics should be accurate
jazzLib, err := ds.Library(ctx).Get(lib2.ID)
Expect(err).ToNot(HaveOccurred())
Expect(jazzLib.TotalSongs).To(Equal(1))
Expect(jazzLib.TotalAlbums).To(Equal(1))
// Rock library may have partial content (depending on scanner implementation)
rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib1.ID},
})
Expect(err).ToNot(HaveOccurred())
// No specific expectation - some files may have been imported despite errors
_ = rockFiles
// Error should be empty (warnings don't count as scan errors)
lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset")
Expect(err).ToNot(HaveOccurred())
Expect(lastError).To(BeEmpty())
})
It("should handle partial failures gracefully", func() {
// Create a scenario where rock has filesystem issues and jazz has normal content
rockFS.SetError("Metallica/Master of Puppets/01 - Battery.mp3", errors.New("file corruption"))
// Do an initial scan with filesystem error
warnings, err := s.ScanAll(ctx, true)
Expect(err).ToNot(HaveOccurred())
Expect(warnings).ToNot(BeEmpty(), "Should have warnings for file corruption")
// Verify that the working parts completed successfully
jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib2.ID},
})
Expect(err).ToNot(HaveOccurred())
Expect(jazzFiles).To(HaveLen(1))
// Scanner properties should reflect successful completion despite warnings
scanType, _ := ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "")
Expect(scanType).To(Equal("full"))
// Start time should be recorded
startTimeStr, _ := ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "")
Expect(startTimeStr).ToNot(BeEmpty())
// Error should be empty (warnings don't count as scan errors)
lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset")
Expect(err).ToNot(HaveOccurred())
Expect(lastError).To(BeEmpty())
})
})
Context("Error recovery in multi-library context", func() {
It("should recover from previous library-specific errors", func() {
// Set up initial content
rock := template(_t{"albumartist": "Iron Maiden", "album": "The Number of the Beast", "year": 1982, "genre": "Metal"})
jazz := template(_t{"albumartist": "John Coltrane", "album": "Giant Steps", "year": 1960, "genre": "Jazz"})
rockFS := createFS("rock", fstest.MapFS{
"Iron Maiden/The Number of the Beast/01 - Invaders.mp3": rock(track(1, "Invaders")),
})
createFS("jazz", fstest.MapFS{
"John Coltrane/Giant Steps/01 - Giant Steps.mp3": jazz(track(1, "Giant Steps")),
})
// First scan with filesystem error in rock
rockFS.SetError("Iron Maiden/The Number of the Beast/01 - Invaders.mp3", errors.New("temporary disk error"))
warnings, err := s.ScanAll(ctx, true)
Expect(err).ToNot(HaveOccurred()) // Should succeed with warnings
Expect(warnings).ToNot(BeEmpty(), "Should have warnings for temporary disk error")
// Clear the error and add more content - recreate the filesystem completely
rockFS.ClearError("Iron Maiden/The Number of the Beast/01 - Invaders.mp3")
// Create a new filesystem with both files
createFS("rock", fstest.MapFS{
"Iron Maiden/The Number of the Beast/01 - Invaders.mp3": rock(track(1, "Invaders")),
"Iron Maiden/The Number of the Beast/02 - Children of the Damned.mp3": rock(track(2, "Children of the Damned")),
})
// Second scan should recover and import all rock content
warnings, err = s.ScanAll(ctx, true)
Expect(err).ToNot(HaveOccurred())
Expect(warnings).ToNot(BeEmpty(), "Should have warnings for temporary disk error")
// Verify both libraries now have content (at least jazz should work)
rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib1.ID},
})
Expect(err).ToNot(HaveOccurred())
// The scanner should recover and import both rock files
Expect(len(rockFiles)).To(Equal(2))
jazzFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"library_id": lib2.ID},
})
Expect(err).ToNot(HaveOccurred())
Expect(jazzFiles).To(HaveLen(1))
// Both libraries should have correct content counts
rockLib, err := ds.Library(ctx).Get(lib1.ID)
Expect(err).ToNot(HaveOccurred())
Expect(rockLib.TotalSongs).To(Equal(2))
jazzLib, err := ds.Library(ctx).Get(lib2.ID)
Expect(err).ToNot(HaveOccurred())
Expect(jazzLib.TotalSongs).To(Equal(1))
// Error should be empty (successful recovery)
lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset")
Expect(err).ToNot(HaveOccurred())
Expect(lastError).To(BeEmpty())
})
})
})
Context("Scanner Properties", func() {
It("should persist last scan type, start time and error properties", func() {
// trivial FS setup
rock := template(_t{"albumartist": "AC/DC", "album": "Back in Black", "year": 1980, "genre": "Rock"})
_ = createFS("rock", fstest.MapFS{
"AC-DC/Back in Black/01 - Hells Bells.mp3": rock(track(1, "Hells Bells")),
})
// Run a full scan
Expect(runScanner(ctx, true)).To(Succeed())
// Validate properties
scanType, _ := ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "")
Expect(scanType).To(Equal("full"))
startTimeStr, _ := ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "")
Expect(startTimeStr).ToNot(BeEmpty())
_, err := time.Parse(time.RFC3339, startTimeStr)
Expect(err).ToNot(HaveOccurred())
lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset")
Expect(err).ToNot(HaveOccurred())
Expect(lastError).To(BeEmpty())
})
})
})

View File

@@ -58,12 +58,14 @@ var _ = Describe("Scanner", Ordered, func() {
}) })
BeforeEach(func() { BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.MusicFolder = "fake:///music" // Set to match test library path
conf.Server.DevExternalScanner = false
db.Init(ctx) db.Init(ctx)
DeferCleanup(func() { DeferCleanup(func() {
Expect(tests.ClearDB()).To(Succeed()) Expect(tests.ClearDB()).To(Succeed())
}) })
DeferCleanup(configtest.SetupConfig())
conf.Server.DevExternalScanner = false
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
mfRepo = &mockMediaFileRepo{ mfRepo = &mockMediaFileRepo{
@@ -71,6 +73,16 @@ var _ = Describe("Scanner", Ordered, func() {
} }
ds.MockedMediaFile = mfRepo ds.MockedMediaFile = mfRepo
// Create the admin user in the database to match the context
adminUser := model.User{
ID: "123",
UserName: "admin",
Name: "Admin User",
IsAdmin: true,
NewPassword: "password",
}
Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
core.NewPlaylists(ds), metrics.NewNoopInstance()) core.NewPlaylists(ds), metrics.NewNoopInstance())

View File

@@ -5,42 +5,67 @@ import (
"fmt" "fmt"
"io/fs" "io/fs"
"path/filepath" "path/filepath"
"sync"
"time" "time"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/singleton"
) )
type Watcher interface { type Watcher interface {
Run(ctx context.Context) error Run(ctx context.Context) error
Watch(ctx context.Context, lib *model.Library) error
StopWatching(ctx context.Context, libraryID int) error
} }
type watcher struct { type watcher struct {
ds model.DataStore mainCtx context.Context
scanner Scanner ds model.DataStore
triggerWait time.Duration scanner Scanner
triggerWait time.Duration
watcherNotify chan model.Library
libraryWatchers map[int]*libraryWatcherInstance
mu sync.RWMutex
} }
func NewWatcher(ds model.DataStore, s Scanner) Watcher { type libraryWatcherInstance struct {
return &watcher{ds: ds, scanner: s, triggerWait: conf.Server.Scanner.WatcherWait} library *model.Library
cancel context.CancelFunc
}
// GetWatcher returns the watcher singleton
func GetWatcher(ds model.DataStore, s Scanner) Watcher {
return singleton.GetInstance(func() *watcher {
return &watcher{
ds: ds,
scanner: s,
triggerWait: conf.Server.Scanner.WatcherWait,
watcherNotify: make(chan model.Library, 1),
libraryWatchers: make(map[int]*libraryWatcherInstance),
}
})
} }
func (w *watcher) Run(ctx context.Context) error { func (w *watcher) Run(ctx context.Context) error {
// Keep the main context to be used in all watchers added later
w.mainCtx = ctx
// Start watchers for all existing libraries
libs, err := w.ds.Library(ctx).GetAll() libs, err := w.ds.Library(ctx).GetAll()
if err != nil { if err != nil {
return fmt.Errorf("getting libraries: %w", err) return fmt.Errorf("getting libraries: %w", err)
} }
watcherChan := make(chan struct{})
defer close(watcherChan)
// Start a watcher for each library
for _, lib := range libs { for _, lib := range libs {
go watchLib(ctx, lib, watcherChan) if err := w.Watch(ctx, &lib); err != nil {
log.Warn(ctx, "Failed to start watcher for existing library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
}
} }
// Main scan triggering loop
trigger := time.NewTimer(w.triggerWait) trigger := time.NewTimer(w.triggerWait)
trigger.Stop() trigger.Stop()
waiting := false waiting := false
@@ -68,61 +93,137 @@ func (w *watcher) Run(ctx context.Context) error {
} }
}() }()
case <-ctx.Done(): case <-ctx.Done():
// Stop all library watchers
w.mu.Lock()
for libraryID, instance := range w.libraryWatchers {
log.Debug(ctx, "Stopping library watcher due to context cancellation", "libraryID", libraryID)
instance.cancel()
}
w.libraryWatchers = make(map[int]*libraryWatcherInstance)
w.mu.Unlock()
return nil return nil
case <-watcherChan: case lib := <-w.watcherNotify:
if !waiting { if !waiting {
log.Debug(ctx, "Watcher: Detected changes. Waiting for more changes before triggering scan") log.Debug(ctx, "Watcher: Detected changes. Waiting for more changes before triggering scan",
"libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
waiting = true waiting = true
} }
trigger.Reset(w.triggerWait) trigger.Reset(w.triggerWait)
} }
} }
} }
func watchLib(ctx context.Context, lib model.Library, watchChan chan struct{}) { func (w *watcher) Watch(ctx context.Context, lib *model.Library) error {
w.mu.Lock()
defer w.mu.Unlock()
// Stop existing watcher if any
if existingInstance, exists := w.libraryWatchers[lib.ID]; exists {
log.Debug(ctx, "Stopping existing watcher before starting new one", "libraryID", lib.ID, "name", lib.Name)
existingInstance.cancel()
}
// Start new watcher
watcherCtx, cancel := context.WithCancel(w.mainCtx)
instance := &libraryWatcherInstance{
library: lib,
cancel: cancel,
}
w.libraryWatchers[lib.ID] = instance
// Start watching in a goroutine
go func() {
defer func() {
w.mu.Lock()
if currentInstance, exists := w.libraryWatchers[lib.ID]; exists && currentInstance == instance {
delete(w.libraryWatchers, lib.ID)
}
w.mu.Unlock()
}()
err := w.watchLibrary(watcherCtx, lib)
if err != nil && watcherCtx.Err() == nil { // Only log error if not due to cancellation
log.Error(ctx, "Watcher error", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
}
}()
log.Info(ctx, "Started watcher for library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
return nil
}
func (w *watcher) StopWatching(ctx context.Context, libraryID int) error {
w.mu.Lock()
defer w.mu.Unlock()
instance, exists := w.libraryWatchers[libraryID]
if !exists {
log.Debug(ctx, "No watcher found to stop", "libraryID", libraryID)
return nil
}
instance.cancel()
delete(w.libraryWatchers, libraryID)
log.Info(ctx, "Stopped watcher for library", "libraryID", libraryID, "name", instance.library.Name)
return nil
}
// watchLibrary implements the core watching logic for a single library (extracted from old watchLib function)
func (w *watcher) watchLibrary(ctx context.Context, lib *model.Library) error {
s, err := storage.For(lib.Path) s, err := storage.For(lib.Path)
if err != nil { if err != nil {
log.Error(ctx, "Watcher: Error creating storage", "library", lib.ID, "path", lib.Path, err) return fmt.Errorf("creating storage: %w", err)
return
} }
fsys, err := s.FS() fsys, err := s.FS()
if err != nil { if err != nil {
log.Error(ctx, "Watcher: Error getting FS", "library", lib.ID, "path", lib.Path, err) return fmt.Errorf("getting FS: %w", err)
return
} }
watcher, ok := s.(storage.Watcher) watcher, ok := s.(storage.Watcher)
if !ok { if !ok {
log.Info(ctx, "Watcher not supported", "library", lib.ID, "path", lib.Path) log.Info(ctx, "Watcher not supported for storage type", "libraryID", lib.ID, "path", lib.Path)
return return nil
} }
c, err := watcher.Start(ctx) c, err := watcher.Start(ctx)
if err != nil { if err != nil {
log.Error(ctx, "Watcher: Error watching library", "library", lib.ID, "path", lib.Path, err) return fmt.Errorf("starting watcher: %w", err)
return
} }
absLibPath, err := filepath.Abs(lib.Path) absLibPath, err := filepath.Abs(lib.Path)
if err != nil { if err != nil {
log.Error(ctx, "Watcher: Error converting lib.Path to absolute", "library", lib.ID, "path", lib.Path, err) return fmt.Errorf("converting to absolute path: %w", err)
return
} }
log.Info(ctx, "Watcher started", "library", lib.ID, "libPath", lib.Path, "absoluteLibPath", absLibPath)
log.Info(ctx, "Watcher started for library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, "absoluteLibPath", absLibPath)
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return log.Debug(ctx, "Watcher stopped due to context cancellation", "libraryID", lib.ID, "name", lib.Name)
return nil
case path := <-c: case path := <-c:
path, err = filepath.Rel(absLibPath, path) path, err = filepath.Rel(absLibPath, path)
if err != nil { if err != nil {
log.Error(ctx, "Watcher: Error getting relative path", "library", lib.ID, "libPath", absLibPath, "path", path, err) log.Error(ctx, "Error getting relative path", "libraryID", lib.ID, "absolutePath", absLibPath, "path", path, err)
continue continue
} }
if isIgnoredPath(ctx, fsys, path) { if isIgnoredPath(ctx, fsys, path) {
log.Trace(ctx, "Watcher: Ignoring change", "library", lib.ID, "path", path) log.Trace(ctx, "Ignoring change", "libraryID", lib.ID, "path", path)
continue continue
} }
log.Trace(ctx, "Watcher: Detected change", "library", lib.ID, "path", path, "libPath", absLibPath)
watchChan <- struct{}{} log.Trace(ctx, "Detected change", "libraryID", lib.ID, "path", path, "absoluteLibPath", absLibPath)
// Notify the main watcher of changes
select {
case w.watcherNotify <- *lib:
default:
// Channel is full, notification already pending
}
} }
} }
} }

View File

@@ -13,8 +13,8 @@ type eventCtxKey string
const broadcastToAllKey eventCtxKey = "broadcastToAll" const broadcastToAllKey eventCtxKey = "broadcastToAll"
// BroadcastToAll is a context key that can be used to broadcast an event to all clients // broadcastToAll is a context key that can be used to broadcast an event to all clients
func BroadcastToAll(ctx context.Context) context.Context { func broadcastToAll(ctx context.Context) context.Context {
return context.WithValue(ctx, broadcastToAllKey, true) return context.WithValue(ctx, broadcastToAllKey, true)
} }

View File

@@ -19,6 +19,7 @@ import (
type Broker interface { type Broker interface {
http.Handler http.Handler
SendMessage(ctx context.Context, event Event) SendMessage(ctx context.Context, event Event)
SendBroadcastMessage(ctx context.Context, event Event)
} }
const ( const (
@@ -77,6 +78,11 @@ func GetBroker() Broker {
}) })
} }
func (b *broker) SendBroadcastMessage(ctx context.Context, evt Event) {
ctx = broadcastToAll(ctx)
b.SendMessage(ctx, evt)
}
func (b *broker) SendMessage(ctx context.Context, evt Event) { func (b *broker) SendMessage(ctx context.Context, evt Event) {
msg := b.prepareMessage(ctx, evt) msg := b.prepareMessage(ctx, evt)
log.Trace("Broker received new event", "type", msg.event, "data", msg.data) log.Trace("Broker received new event", "type", msg.event, "data", msg.data)
@@ -280,4 +286,6 @@ type noopBroker struct {
http.Handler http.Handler
} }
func (b noopBroker) SendBroadcastMessage(context.Context, Event) {}
func (noopBroker) SendMessage(context.Context, Event) {} func (noopBroker) SendMessage(context.Context, Event) {}

View File

@@ -9,7 +9,6 @@ import (
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/request"
) )
// sensitiveFieldsPartialMask contains configuration field names that should be redacted // sensitiveFieldsPartialMask contains configuration field names that should be redacted
@@ -99,11 +98,6 @@ func applySensitiveFieldMasking(ctx context.Context, config map[string]interface
func getConfig(w http.ResponseWriter, r *http.Request) { func getConfig(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
user, _ := request.UserFrom(ctx)
if !user.IsAdmin {
http.Error(w, "Config endpoint is only available to admin users", http.StatusUnauthorized)
return
}
// Marshal the actual configuration struct to preserve original field names // Marshal the actual configuration struct to preserve original field names
configBytes, err := json.Marshal(*conf.Server) configBytes, err := json.Marshal(*conf.Server)

View File

@@ -1,109 +1,171 @@
package nativeapi package nativeapi
import ( import (
"bytes"
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
) )
var _ = Describe("getConfig", func() { var _ = Describe("Config API", func() {
var ds model.DataStore
var router http.Handler
var adminUser, regularUser model.User
BeforeEach(func() { BeforeEach(func() {
DeferCleanup(configtest.SetupConfig()) DeferCleanup(configtest.SetupConfig())
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
ds = &tests.MockDataStore{}
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
router = server.JWTVerifier(nativeRouter)
// Create test users
adminUser = model.User{
ID: "admin-1",
UserName: "admin",
Name: "Admin User",
IsAdmin: true,
NewPassword: "adminpass",
}
regularUser = model.User{
ID: "user-1",
UserName: "regular",
Name: "Regular User",
IsAdmin: false,
NewPassword: "userpass",
}
// Store in mock datastore
Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed())
Expect(ds.User(context.TODO()).Put(&regularUser)).To(Succeed())
}) })
Context("when user is not admin", func() { Describe("GET /api/config", func() {
It("returns unauthorized", func() { Context("as admin user", func() {
req := httptest.NewRequest("GET", "/config", nil) var adminToken string
w := httptest.NewRecorder()
ctx := request.WithUser(req.Context(), model.User{IsAdmin: false})
getConfig(w, req.WithContext(ctx)) BeforeEach(func() {
var err error
adminToken, err = auth.CreateToken(&adminUser)
Expect(err).ToNot(HaveOccurred())
})
Expect(w.Code).To(Equal(http.StatusUnauthorized)) It("returns config successfully", func() {
}) req := createAuthenticatedConfigRequest(adminToken)
}) w := httptest.NewRecorder()
Context("when user is admin", func() { router.ServeHTTP(w, req)
It("returns config successfully", func() {
req := httptest.NewRequest("GET", "/config", nil)
w := httptest.NewRecorder()
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
getConfig(w, req.WithContext(ctx)) Expect(w.Code).To(Equal(http.StatusOK))
var resp configResponse
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
Expect(resp.ID).To(Equal("config"))
Expect(resp.ConfigFile).To(Equal(conf.Server.ConfigFile))
Expect(resp.Config).ToNot(BeEmpty())
})
Expect(w.Code).To(Equal(http.StatusOK)) It("redacts sensitive fields", func() {
var resp configResponse conf.Server.LastFM.ApiKey = "secretapikey123"
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) conf.Server.Spotify.Secret = "spotifysecret456"
Expect(resp.ID).To(Equal("config")) conf.Server.PasswordEncryptionKey = "encryptionkey789"
Expect(resp.ConfigFile).To(Equal(conf.Server.ConfigFile)) conf.Server.DevAutoCreateAdminPassword = "adminpassword123"
Expect(resp.Config).ToNot(BeEmpty()) conf.Server.Prometheus.Password = "prometheuspass"
req := createAuthenticatedConfigRequest(adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var resp configResponse
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
// Check LastFM.ApiKey (partially masked)
lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
Expect(ok).To(BeTrue())
Expect(lastfm["ApiKey"]).To(Equal("s*************3"))
// Check Spotify.Secret (partially masked)
spotify, ok := resp.Config["Spotify"].(map[string]interface{})
Expect(ok).To(BeTrue())
Expect(spotify["Secret"]).To(Equal("s**************6"))
// Check PasswordEncryptionKey (fully masked)
Expect(resp.Config["PasswordEncryptionKey"]).To(Equal("****"))
// Check DevAutoCreateAdminPassword (fully masked)
Expect(resp.Config["DevAutoCreateAdminPassword"]).To(Equal("****"))
// Check Prometheus.Password (fully masked)
prometheus, ok := resp.Config["Prometheus"].(map[string]interface{})
Expect(ok).To(BeTrue())
Expect(prometheus["Password"]).To(Equal("****"))
})
It("handles empty sensitive values", func() {
conf.Server.LastFM.ApiKey = ""
conf.Server.PasswordEncryptionKey = ""
req := createAuthenticatedConfigRequest(adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var resp configResponse
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
// Check LastFM.ApiKey - should be preserved because it's sensitive
lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
Expect(ok).To(BeTrue())
Expect(lastfm["ApiKey"]).To(Equal(""))
// Empty sensitive values should remain empty - should be preserved because it's sensitive
Expect(resp.Config["PasswordEncryptionKey"]).To(Equal(""))
})
}) })
It("redacts sensitive fields", func() { Context("as regular user", func() {
conf.Server.LastFM.ApiKey = "secretapikey123" var userToken string
conf.Server.Spotify.Secret = "spotifysecret456"
conf.Server.PasswordEncryptionKey = "encryptionkey789"
conf.Server.DevAutoCreateAdminPassword = "adminpassword123"
conf.Server.Prometheus.Password = "prometheuspass"
req := httptest.NewRequest("GET", "/config", nil) BeforeEach(func() {
w := httptest.NewRecorder() var err error
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true}) userToken, err = auth.CreateToken(&regularUser)
getConfig(w, req.WithContext(ctx)) Expect(err).ToNot(HaveOccurred())
})
Expect(w.Code).To(Equal(http.StatusOK)) It("denies access with forbidden status", func() {
var resp configResponse req := createAuthenticatedConfigRequest(userToken)
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) w := httptest.NewRecorder()
// Check LastFM.ApiKey (partially masked) router.ServeHTTP(w, req)
lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
Expect(ok).To(BeTrue())
Expect(lastfm["ApiKey"]).To(Equal("s*************3"))
// Check Spotify.Secret (partially masked) Expect(w.Code).To(Equal(http.StatusForbidden))
spotify, ok := resp.Config["Spotify"].(map[string]interface{}) })
Expect(ok).To(BeTrue())
Expect(spotify["Secret"]).To(Equal("s**************6"))
// Check PasswordEncryptionKey (fully masked)
Expect(resp.Config["PasswordEncryptionKey"]).To(Equal("****"))
// Check DevAutoCreateAdminPassword (fully masked)
Expect(resp.Config["DevAutoCreateAdminPassword"]).To(Equal("****"))
// Check Prometheus.Password (fully masked)
prometheus, ok := resp.Config["Prometheus"].(map[string]interface{})
Expect(ok).To(BeTrue())
Expect(prometheus["Password"]).To(Equal("****"))
}) })
It("handles empty sensitive values", func() { Context("without authentication", func() {
conf.Server.LastFM.ApiKey = "" It("denies access with unauthorized status", func() {
conf.Server.PasswordEncryptionKey = "" req := createUnauthenticatedConfigRequest("GET", "/config/", nil)
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/config", nil) router.ServeHTTP(w, req)
w := httptest.NewRecorder()
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
getConfig(w, req.WithContext(ctx))
Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Code).To(Equal(http.StatusUnauthorized))
var resp configResponse })
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
// Check LastFM.ApiKey - should be preserved because it's sensitive
lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
Expect(ok).To(BeTrue())
Expect(lastfm["ApiKey"]).To(Equal(""))
// Empty sensitive values should remain empty - should be preserved because it's sensitive
Expect(resp.Config["PasswordEncryptionKey"]).To(Equal(""))
}) })
}) })
}) })
@@ -145,3 +207,21 @@ var _ = Describe("redactValue function", func() {
Expect(redactValue("LastFM.ApiKey", "abcdefg")).To(Equal("a*****g")) Expect(redactValue("LastFM.ApiKey", "abcdefg")).To(Equal("a*****g"))
}) })
}) })
// Helper functions
func createAuthenticatedConfigRequest(token string) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/config/config", nil)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
return req
}
func createUnauthenticatedConfigRequest(method, path string, body *bytes.Buffer) *http.Request {
if body == nil {
body = &bytes.Buffer{}
}
req := httptest.NewRequest(method, path, body)
req.Header.Set("Content-Type", "application/json")
return req
}

View File

@@ -9,7 +9,6 @@ import (
"github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/req"
) )
@@ -30,11 +29,6 @@ func inspect(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
user, _ := request.UserFrom(ctx)
if !user.IsAdmin {
http.Error(w, "Inspect is only available to admin users", http.StatusUnauthorized)
}
p := req.Params(r) p := req.Params(r)
id, err := p.String("id") id, err := p.String("id")

101
server/nativeapi/library.go Normal file
View File

@@ -0,0 +1,101 @@
package nativeapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
// User-library association endpoints (admin only)
func (n *Router) addUserLibraryRoute(r chi.Router) {
r.Route("/user/{id}/library", func(r chi.Router) {
r.Use(parseUserIDMiddleware)
r.Get("/", getUserLibraries(n.libs))
r.Put("/", setUserLibraries(n.libs))
})
}
// Middleware to parse user ID from URL
func parseUserIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "id")
if userID == "" {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
ctx := context.WithValue(r.Context(), "userID", userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// User-library association handlers
func getUserLibraries(service core.Library) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("userID").(string)
libraries, err := service.GetUserLibraries(r.Context(), userID)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
http.Error(w, "User not found", http.StatusNotFound)
return
}
log.Error(r.Context(), "Error getting user libraries", "userID", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(libraries); err != nil {
log.Error(r.Context(), "Error encoding user libraries response", err)
}
}
}
func setUserLibraries(service core.Library) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("userID").(string)
var request struct {
LibraryIDs []int `json:"libraryIds"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
log.Error(r.Context(), "Error decoding request", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if err := service.SetUserLibraries(r.Context(), userID, request.LibraryIDs); err != nil {
log.Error(r.Context(), "Error setting user libraries", "userID", userID, err)
if errors.Is(err, model.ErrNotFound) {
http.Error(w, "User not found", http.StatusNotFound)
return
}
if errors.Is(err, model.ErrValidation) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Error(w, "Failed to set user libraries", http.StatusInternalServerError)
return
}
// Return updated user libraries
libraries, err := service.GetUserLibraries(r.Context(), userID)
if err != nil {
log.Error(r.Context(), "Error getting updated user libraries", "userID", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(libraries); err != nil {
log.Error(r.Context(), "Error encoding user libraries response", err)
}
}
}

View File

@@ -0,0 +1,424 @@
package nativeapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Library API", func() {
var ds model.DataStore
var router http.Handler
var adminUser, regularUser model.User
var library1, library2 model.Library
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ds = &tests.MockDataStore{}
auth.Init(ds)
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
router = server.JWTVerifier(nativeRouter)
// Create test users
adminUser = model.User{
ID: "admin-1",
UserName: "admin",
Name: "Admin User",
IsAdmin: true,
NewPassword: "adminpass",
}
regularUser = model.User{
ID: "user-1",
UserName: "regular",
Name: "Regular User",
IsAdmin: false,
NewPassword: "userpass",
}
// Create test libraries
library1 = model.Library{
ID: 1,
Name: "Test Library 1",
Path: "/music/library1",
}
library2 = model.Library{
ID: 2,
Name: "Test Library 2",
Path: "/music/library2",
}
// Store in mock datastore
Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed())
Expect(ds.User(context.TODO()).Put(&regularUser)).To(Succeed())
Expect(ds.Library(context.TODO()).Put(&library1)).To(Succeed())
Expect(ds.Library(context.TODO()).Put(&library2)).To(Succeed())
})
Describe("Library CRUD Operations", func() {
Context("as admin user", func() {
var adminToken string
BeforeEach(func() {
var err error
adminToken, err = auth.CreateToken(&adminUser)
Expect(err).ToNot(HaveOccurred())
})
Describe("GET /api/library", func() {
It("returns all libraries", func() {
req := createAuthenticatedRequest("GET", "/library", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var libraries []model.Library
err := json.Unmarshal(w.Body.Bytes(), &libraries)
Expect(err).ToNot(HaveOccurred())
Expect(libraries).To(HaveLen(2))
Expect(libraries[0].Name).To(Equal("Test Library 1"))
Expect(libraries[1].Name).To(Equal("Test Library 2"))
})
})
Describe("GET /api/library/{id}", func() {
It("returns a specific library", func() {
req := createAuthenticatedRequest("GET", "/library/1", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var library model.Library
err := json.Unmarshal(w.Body.Bytes(), &library)
Expect(err).ToNot(HaveOccurred())
Expect(library.Name).To(Equal("Test Library 1"))
Expect(library.Path).To(Equal("/music/library1"))
})
It("returns 404 for non-existent library", func() {
req := createAuthenticatedRequest("GET", "/library/999", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("returns 400 for invalid library ID", func() {
req := createAuthenticatedRequest("GET", "/library/invalid", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
Describe("POST /api/library", func() {
It("creates a new library", func() {
newLibrary := model.Library{
Name: "New Library",
Path: "/music/new",
}
body, _ := json.Marshal(newLibrary)
req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
})
It("validates required fields", func() {
invalidLibrary := model.Library{
Name: "", // Missing name
Path: "/music/invalid",
}
body, _ := json.Marshal(invalidLibrary)
req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(w.Body.String()).To(ContainSubstring("library name is required"))
})
It("validates path field", func() {
invalidLibrary := model.Library{
Name: "Valid Name",
Path: "", // Missing path
}
body, _ := json.Marshal(invalidLibrary)
req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(w.Body.String()).To(ContainSubstring("library path is required"))
})
})
Describe("PUT /api/library/{id}", func() {
It("updates an existing library", func() {
updatedLibrary := model.Library{
Name: "Updated Library 1",
Path: "/music/updated",
}
body, _ := json.Marshal(updatedLibrary)
req := createAuthenticatedRequest("PUT", "/library/1", bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var updated model.Library
err := json.Unmarshal(w.Body.Bytes(), &updated)
Expect(err).ToNot(HaveOccurred())
Expect(updated.ID).To(Equal(1))
Expect(updated.Name).To(Equal("Updated Library 1"))
Expect(updated.Path).To(Equal("/music/updated"))
})
It("validates required fields on update", func() {
invalidLibrary := model.Library{
Name: "",
Path: "/music/path",
}
body, _ := json.Marshal(invalidLibrary)
req := createAuthenticatedRequest("PUT", "/library/1", bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
})
Describe("DELETE /api/library/{id}", func() {
It("deletes an existing library", func() {
req := createAuthenticatedRequest("DELETE", "/library/1", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
})
It("returns 404 for non-existent library", func() {
req := createAuthenticatedRequest("DELETE", "/library/999", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
})
Context("as regular user", func() {
var userToken string
BeforeEach(func() {
var err error
userToken, err = auth.CreateToken(&regularUser)
Expect(err).ToNot(HaveOccurred())
})
It("denies access to library management endpoints", func() {
endpoints := []string{
"GET /library",
"POST /library",
"GET /library/1",
"PUT /library/1",
"DELETE /library/1",
}
for _, endpoint := range endpoints {
parts := strings.Split(endpoint, " ")
method, path := parts[0], parts[1]
req := createAuthenticatedRequest(method, path, nil, userToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusForbidden))
}
})
})
Context("without authentication", func() {
It("denies access to library management endpoints", func() {
req := createUnauthenticatedRequest("GET", "/library", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
})
Describe("User-Library Association Operations", func() {
Context("as admin user", func() {
var adminToken string
BeforeEach(func() {
var err error
adminToken, err = auth.CreateToken(&adminUser)
Expect(err).ToNot(HaveOccurred())
})
Describe("GET /api/user/{id}/library", func() {
It("returns user's libraries", func() {
// Set up user libraries
err := ds.User(context.TODO()).SetUserLibraries(regularUser.ID, []int{1, 2})
Expect(err).ToNot(HaveOccurred())
req := createAuthenticatedRequest("GET", fmt.Sprintf("/user/%s/library", regularUser.ID), nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var libraries []model.Library
err = json.Unmarshal(w.Body.Bytes(), &libraries)
Expect(err).ToNot(HaveOccurred())
Expect(libraries).To(HaveLen(2))
})
It("returns 404 for non-existent user", func() {
req := createAuthenticatedRequest("GET", "/user/non-existent/library", nil, adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
Describe("PUT /api/user/{id}/library", func() {
It("sets user's libraries", func() {
request := map[string][]int{
"libraryIds": {1, 2},
}
body, _ := json.Marshal(request)
req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var libraries []model.Library
err := json.Unmarshal(w.Body.Bytes(), &libraries)
Expect(err).ToNot(HaveOccurred())
Expect(libraries).To(HaveLen(2))
})
It("validates library IDs exist", func() {
request := map[string][]int{
"libraryIds": {999}, // Non-existent library
}
body, _ := json.Marshal(request)
req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(w.Body.String()).To(ContainSubstring("library ID 999 does not exist"))
})
It("requires at least one library for regular users", func() {
request := map[string][]int{
"libraryIds": {}, // Empty libraries
}
body, _ := json.Marshal(request)
req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(w.Body.String()).To(ContainSubstring("at least one library must be assigned"))
})
It("prevents manual assignment to admin users", func() {
request := map[string][]int{
"libraryIds": {1},
}
body, _ := json.Marshal(request)
req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", adminUser.ID), bytes.NewBuffer(body), adminToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(w.Body.String()).To(ContainSubstring("cannot manually assign libraries to admin users"))
})
})
})
Context("as regular user", func() {
var userToken string
BeforeEach(func() {
var err error
userToken, err = auth.CreateToken(&regularUser)
Expect(err).ToNot(HaveOccurred())
})
It("denies access to user-library association endpoints", func() {
req := createAuthenticatedRequest("GET", fmt.Sprintf("/user/%s/library", regularUser.ID), nil, userToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusForbidden))
})
})
})
})
// Helper functions
func createAuthenticatedRequest(method, path string, body *bytes.Buffer, token string) *http.Request {
if body == nil {
body = &bytes.Buffer{}
}
req := httptest.NewRequest(method, path, body)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
return req
}
func createUnauthenticatedRequest(method, path string, body *bytes.Buffer) *http.Request {
if body == nil {
body = &bytes.Buffer{}
}
req := httptest.NewRequest(method, path, body)
req.Header.Set("Content-Type", "application/json")
return req
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server"
) )
@@ -25,10 +26,11 @@ type Router struct {
share core.Share share core.Share
playlists core.Playlists playlists core.Playlists
insights metrics.Insights insights metrics.Insights
libs core.Library
} }
func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights) *Router { func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library) *Router {
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights} r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService}
r.Handler = r.routes() r.Handler = r.routes()
return r return r
} }
@@ -62,10 +64,15 @@ func (n *Router) routes() http.Handler {
n.addSongPlaylistsRoute(r) n.addSongPlaylistsRoute(r)
n.addQueueRoute(r) n.addQueueRoute(r)
n.addMissingFilesRoute(r) n.addMissingFilesRoute(r)
n.addInspectRoute(r)
n.addConfigRoute(r)
n.addKeepAliveRoute(r) n.addKeepAliveRoute(r)
n.addInsightsRoute(r) n.addInsightsRoute(r)
r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
n.addInspectRoute(r)
n.addConfigRoute(r)
n.addUserLibraryRoute(r)
n.RX(r, "/library", n.libs.NewRepository, true)
})
}) })
return r return r
@@ -227,3 +234,15 @@ func (n *Router) addInsightsRoute(r chi.Router) {
} }
}) })
} }
// Middleware to ensure only admin users can access endpoints
func adminOnlyMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := request.UserFrom(r.Context())
if !ok || !user.IsAdmin {
http.Error(w, "Access denied: admin privileges required", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -2,20 +2,17 @@ package nativeapi
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"time" "time"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/tests"
@@ -23,31 +20,6 @@ import (
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
) )
// Simple mock implementations for missing types
type mockShare struct {
core.Share
}
func (m *mockShare) NewRepository(ctx context.Context) rest.Repository {
return &tests.MockShareRepo{}
}
type mockPlaylists struct {
core.Playlists
}
func (m *mockPlaylists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
return &model.Playlist{}, nil
}
type mockInsights struct {
metrics.Insights
}
func (m *mockInsights) LastRun(ctx context.Context) (time.Time, bool) {
return time.Now(), true
}
var _ = Describe("Song Endpoints", func() { var _ = Describe("Song Endpoints", func() {
var ( var (
router http.Handler router http.Handler
@@ -122,13 +94,8 @@ var _ = Describe("Song Endpoints", func() {
} }
mfRepo.SetData(testSongs) mfRepo.SetData(testSongs)
// Setup router with mocked dependencies
mockShareImpl := &mockShare{}
mockPlaylistsImpl := &mockPlaylists{}
mockInsightsImpl := &mockInsights{}
// Create the native API router and wrap it with the JWTVerifier middleware // Create the native API router and wrap it with the JWTVerifier middleware
nativeRouter := New(ds, mockShareImpl, mockPlaylistsImpl, mockInsightsImpl) nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
router = server.JWTVerifier(nativeRouter) router = server.JWTVerifier(nativeRouter)
w = httptest.NewRecorder() w = httptest.NewRecorder()
}) })

View File

@@ -12,6 +12,7 @@ import (
"github.com/navidrome/navidrome/server/subsonic/filter" "github.com/navidrome/navidrome/server/subsonic/filter"
"github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/run"
"github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/slice"
) )
@@ -61,6 +62,13 @@ func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) {
return nil, 0, newError(responses.ErrorGeneric, "type '%s' not implemented", typ) return nil, 0, newError(responses.ErrorGeneric, "type '%s' not implemented", typ)
} }
// Get optional library IDs from musicFolderId parameter
musicFolderIds, err := selectedMusicFolderIds(r, false)
if err != nil {
return nil, 0, err
}
opts = filter.ApplyLibraryFilter(opts, musicFolderIds)
opts.Offset = p.IntOr("offset", 0) opts.Offset = p.IntOr("offset", 0)
opts.Max = min(p.IntOr("size", 10), 500) opts.Max = min(p.IntOr("size", 10), 500)
albums, err := api.ds.Album(r.Context()).GetAll(opts) albums, err := api.ds.Album(r.Context()).GetAll(opts)
@@ -109,57 +117,87 @@ func (api *Router) GetAlbumList2(w http.ResponseWriter, r *http.Request) (*respo
return response, nil return response, nil
} }
func (api *Router) GetStarred(r *http.Request) (*responses.Subsonic, error) { func (api *Router) getStarredItems(r *http.Request) (model.Artists, model.Albums, model.MediaFiles, error) {
ctx := r.Context() ctx := r.Context()
artists, err := api.ds.Artist(ctx).GetAll(filter.ArtistsByStarred())
// Get optional library IDs from musicFolderId parameter
musicFolderIds, err := selectedMusicFolderIds(r, false)
if err != nil { if err != nil {
log.Error(r, "Error retrieving starred artists", err) return nil, nil, nil, err
return nil, err
} }
options := filter.ByStarred()
albums, err := api.ds.Album(ctx).GetAll(options) // Prepare variables to capture results from parallel execution
var artists model.Artists
var albums model.Albums
var mediaFiles model.MediaFiles
// Execute all three queries in parallel for better performance
err = run.Parallel(
// Query starred artists
func() error {
artistOpts := filter.ApplyArtistLibraryFilter(filter.ArtistsByStarred(), musicFolderIds)
var err error
artists, err = api.ds.Artist(ctx).GetAll(artistOpts)
if err != nil {
log.Error(r, "Error retrieving starred artists", err)
}
return err
},
// Query starred albums
func() error {
albumOpts := filter.ApplyLibraryFilter(filter.ByStarred(), musicFolderIds)
var err error
albums, err = api.ds.Album(ctx).GetAll(albumOpts)
if err != nil {
log.Error(r, "Error retrieving starred albums", err)
}
return err
},
// Query starred media files
func() error {
mediaFileOpts := filter.ApplyLibraryFilter(filter.ByStarred(), musicFolderIds)
var err error
mediaFiles, err = api.ds.MediaFile(ctx).GetAll(mediaFileOpts)
if err != nil {
log.Error(r, "Error retrieving starred mediaFiles", err)
}
return err
},
)()
// Return the first error if any occurred
if err != nil { if err != nil {
log.Error(r, "Error retrieving starred albums", err) return nil, nil, nil, err
return nil, err
} }
mediaFiles, err := api.ds.MediaFile(ctx).GetAll(options)
return artists, albums, mediaFiles, nil
}
func (api *Router) GetStarred(r *http.Request) (*responses.Subsonic, error) {
artists, albums, mediaFiles, err := api.getStarredItems(r)
if err != nil { if err != nil {
log.Error(r, "Error retrieving starred mediaFiles", err)
return nil, err return nil, err
} }
response := newResponse() response := newResponse()
response.Starred = &responses.Starred{} response.Starred = &responses.Starred{}
response.Starred.Artist = slice.MapWithArg(artists, r, toArtist) response.Starred.Artist = slice.MapWithArg(artists, r, toArtist)
response.Starred.Album = slice.MapWithArg(albums, ctx, childFromAlbum) response.Starred.Album = slice.MapWithArg(albums, r.Context(), childFromAlbum)
response.Starred.Song = slice.MapWithArg(mediaFiles, ctx, childFromMediaFile) response.Starred.Song = slice.MapWithArg(mediaFiles, r.Context(), childFromMediaFile)
return response, nil return response, nil
} }
func (api *Router) GetStarred2(r *http.Request) (*responses.Subsonic, error) { func (api *Router) GetStarred2(r *http.Request) (*responses.Subsonic, error) {
ctx := r.Context() artists, albums, mediaFiles, err := api.getStarredItems(r)
artists, err := api.ds.Artist(ctx).GetAll(filter.ArtistsByStarred())
if err != nil { if err != nil {
log.Error(r, "Error retrieving starred artists", err)
return nil, err
}
options := filter.ByStarred()
albums, err := api.ds.Album(ctx).GetAll(options)
if err != nil {
log.Error(r, "Error retrieving starred albums", err)
return nil, err
}
mediaFiles, err := api.ds.MediaFile(ctx).GetAll(options)
if err != nil {
log.Error(r, "Error retrieving starred mediaFiles", err)
return nil, err return nil, err
} }
response := newResponse() response := newResponse()
response.Starred2 = &responses.Starred2{} response.Starred2 = &responses.Starred2{}
response.Starred2.Artist = slice.MapWithArg(artists, r, toArtistID3) response.Starred2.Artist = slice.MapWithArg(artists, r, toArtistID3)
response.Starred2.Album = slice.MapWithArg(albums, ctx, buildAlbumID3) response.Starred2.Album = slice.MapWithArg(albums, r.Context(), buildAlbumID3)
response.Starred2.Song = slice.MapWithArg(mediaFiles, ctx, childFromMediaFile) response.Starred2.Song = slice.MapWithArg(mediaFiles, r.Context(), childFromMediaFile)
return response, nil return response, nil
} }
@@ -193,7 +231,15 @@ func (api *Router) GetRandomSongs(r *http.Request) (*responses.Subsonic, error)
fromYear := p.IntOr("fromYear", 0) fromYear := p.IntOr("fromYear", 0)
toYear := p.IntOr("toYear", 0) toYear := p.IntOr("toYear", 0)
songs, err := api.getSongs(r.Context(), 0, size, filter.SongsByRandom(genre, fromYear, toYear)) // Get optional library IDs from musicFolderId parameter
musicFolderIds, err := selectedMusicFolderIds(r, false)
if err != nil {
return nil, err
}
opts := filter.SongsByRandom(genre, fromYear, toYear)
opts = filter.ApplyLibraryFilter(opts, musicFolderIds)
songs, err := api.getSongs(r.Context(), 0, size, opts)
if err != nil { if err != nil {
log.Error(r, "Error retrieving random songs", err) log.Error(r, "Error retrieving random songs", err)
return nil, err return nil, err
@@ -211,8 +257,16 @@ func (api *Router) GetSongsByGenre(r *http.Request) (*responses.Subsonic, error)
offset := p.IntOr("offset", 0) offset := p.IntOr("offset", 0)
genre, _ := p.String("genre") genre, _ := p.String("genre")
// Get optional library IDs from musicFolderId parameter
musicFolderIds, err := selectedMusicFolderIds(r, false)
if err != nil {
return nil, err
}
opts := filter.ByGenre(genre)
opts = filter.ApplyLibraryFilter(opts, musicFolderIds)
ctx := r.Context() ctx := r.Context()
songs, err := api.getSongs(ctx, offset, count, filter.ByGenre(genre)) songs, err := api.getSongs(ctx, offset, count, opts)
if err != nil { if err != nil {
log.Error(r, "Error retrieving random songs", err) log.Error(r, "Error retrieving random songs", err)
return nil, err return nil, err

View File

@@ -5,12 +5,13 @@ import (
"errors" "errors"
"net/http/httptest" "net/http/httptest"
"github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/req"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
) )
@@ -24,6 +25,7 @@ var _ = Describe("Album Lists", func() {
BeforeEach(func() { BeforeEach(func() {
ds = &tests.MockDataStore{} ds = &tests.MockDataStore{}
auth.Init(ds)
mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo) mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w = httptest.NewRecorder() w = httptest.NewRecorder()
@@ -63,6 +65,74 @@ var _ = Describe("Album Lists", func() {
errors.As(err, &subErr) errors.As(err, &subErr)
Expect(subErr.code).To(Equal(responses.ErrorGeneric)) Expect(subErr.code).To(Equal(responses.ErrorGeneric))
}) })
Context("with musicFolderId parameter", func() {
var user model.User
var ctx context.Context
BeforeEach(func() {
user = model.User{
ID: "test-user",
Libraries: []model.Library{
{ID: 1, Name: "Library 1"},
{ID: 2, Name: "Library 2"},
{ID: 3, Name: "Library 3"},
},
}
ctx = request.WithUser(context.Background(), user)
})
It("should filter albums by specific library when musicFolderId is provided", func() {
r := newGetRequest("type=newest", "musicFolderId=1")
r = r.WithContext(ctx)
mockRepo.SetData(model.Albums{
{ID: "1"}, {ID: "2"},
})
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList.Album).To(HaveLen(2))
// Verify that library filter was applied
query, args, _ := mockRepo.Options.Filters.ToSql()
Expect(query).To(ContainSubstring("library_id IN (?)"))
Expect(args).To(ContainElement(1))
})
It("should filter albums by multiple libraries when multiple musicFolderId are provided", func() {
r := newGetRequest("type=newest", "musicFolderId=1", "musicFolderId=2")
r = r.WithContext(ctx)
mockRepo.SetData(model.Albums{
{ID: "1"}, {ID: "2"},
})
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList.Album).To(HaveLen(2))
// Verify that library filter was applied
query, args, _ := mockRepo.Options.Filters.ToSql()
Expect(query).To(ContainSubstring("library_id IN (?,?)"))
Expect(args).To(ContainElements(1, 2))
})
It("should return all accessible albums when no musicFolderId is provided", func() {
r := newGetRequest("type=newest")
r = r.WithContext(ctx)
mockRepo.SetData(model.Albums{
{ID: "1"}, {ID: "2"},
})
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList.Album).To(HaveLen(2))
// Verify that library filter was applied
query, args, _ := mockRepo.Options.Filters.ToSql()
Expect(query).To(ContainSubstring("library_id IN (?,?,?)"))
Expect(args).To(ContainElements(1, 2, 3))
})
})
}) })
Describe("GetAlbumList2", func() { Describe("GetAlbumList2", func() {
@@ -100,5 +170,373 @@ var _ = Describe("Album Lists", func() {
errors.As(err, &subErr) errors.As(err, &subErr)
Expect(subErr.code).To(Equal(responses.ErrorGeneric)) Expect(subErr.code).To(Equal(responses.ErrorGeneric))
}) })
Context("with musicFolderId parameter", func() {
var user model.User
var ctx context.Context
BeforeEach(func() {
user = model.User{
ID: "test-user",
Libraries: []model.Library{
{ID: 1, Name: "Library 1"},
{ID: 2, Name: "Library 2"},
{ID: 3, Name: "Library 3"},
},
}
ctx = request.WithUser(context.Background(), user)
})
It("should filter albums by specific library when musicFolderId is provided", func() {
r := newGetRequest("type=newest", "musicFolderId=1")
r = r.WithContext(ctx)
mockRepo.SetData(model.Albums{
{ID: "1"}, {ID: "2"},
})
resp, err := router.GetAlbumList2(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList2.Album).To(HaveLen(2))
// Verify that library filter was applied
Expect(mockRepo.Options.Filters).ToNot(BeNil())
})
It("should filter albums by multiple libraries when multiple musicFolderId are provided", func() {
r := newGetRequest("type=newest", "musicFolderId=1", "musicFolderId=2")
r = r.WithContext(ctx)
mockRepo.SetData(model.Albums{
{ID: "1"}, {ID: "2"},
})
resp, err := router.GetAlbumList2(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList2.Album).To(HaveLen(2))
// Verify that library filter was applied
Expect(mockRepo.Options.Filters).ToNot(BeNil())
})
It("should return all accessible albums when no musicFolderId is provided", func() {
r := newGetRequest("type=newest")
r = r.WithContext(ctx)
mockRepo.SetData(model.Albums{
{ID: "1"}, {ID: "2"},
})
resp, err := router.GetAlbumList2(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList2.Album).To(HaveLen(2))
})
})
})
Describe("GetRandomSongs", func() {
var mockMediaFileRepo *tests.MockMediaFileRepo
BeforeEach(func() {
mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
})
It("should return random songs", func() {
mockMediaFileRepo.SetData(model.MediaFiles{
{ID: "1", Title: "Song 1"},
{ID: "2", Title: "Song 2"},
})
r := newGetRequest("size=2")
resp, err := router.GetRandomSongs(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.RandomSongs.Songs).To(HaveLen(2))
})
Context("with musicFolderId parameter", func() {
var user model.User
var ctx context.Context
BeforeEach(func() {
user = model.User{
ID: "test-user",
Libraries: []model.Library{
{ID: 1, Name: "Library 1"},
{ID: 2, Name: "Library 2"},
{ID: 3, Name: "Library 3"},
},
}
ctx = request.WithUser(context.Background(), user)
})
It("should filter songs by specific library when musicFolderId is provided", func() {
mockMediaFileRepo.SetData(model.MediaFiles{
{ID: "1", Title: "Song 1"},
{ID: "2", Title: "Song 2"},
})
r := newGetRequest("size=2", "musicFolderId=1")
r = r.WithContext(ctx)
resp, err := router.GetRandomSongs(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.RandomSongs.Songs).To(HaveLen(2))
// Verify that library filter was applied
query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
Expect(query).To(ContainSubstring("library_id IN (?)"))
Expect(args).To(ContainElement(1))
})
It("should filter songs by multiple libraries when multiple musicFolderId are provided", func() {
mockMediaFileRepo.SetData(model.MediaFiles{
{ID: "1", Title: "Song 1"},
{ID: "2", Title: "Song 2"},
})
r := newGetRequest("size=2", "musicFolderId=1", "musicFolderId=2")
r = r.WithContext(ctx)
resp, err := router.GetRandomSongs(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.RandomSongs.Songs).To(HaveLen(2))
// Verify that library filter was applied
query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
Expect(query).To(ContainSubstring("library_id IN (?,?)"))
Expect(args).To(ContainElements(1, 2))
})
It("should return all accessible songs when no musicFolderId is provided", func() {
mockMediaFileRepo.SetData(model.MediaFiles{
{ID: "1", Title: "Song 1"},
{ID: "2", Title: "Song 2"},
})
r := newGetRequest("size=2")
r = r.WithContext(ctx)
resp, err := router.GetRandomSongs(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.RandomSongs.Songs).To(HaveLen(2))
// Verify that library filter was applied
query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
Expect(query).To(ContainSubstring("library_id IN (?,?,?)"))
Expect(args).To(ContainElements(1, 2, 3))
})
})
})
Describe("GetSongsByGenre", func() {
var mockMediaFileRepo *tests.MockMediaFileRepo
BeforeEach(func() {
mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
})
It("should return songs by genre", func() {
mockMediaFileRepo.SetData(model.MediaFiles{
{ID: "1", Title: "Song 1"},
{ID: "2", Title: "Song 2"},
})
r := newGetRequest("count=2", "genre=rock")
resp, err := router.GetSongsByGenre(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.SongsByGenre.Songs).To(HaveLen(2))
})
Context("with musicFolderId parameter", func() {
var user model.User
var ctx context.Context
BeforeEach(func() {
user = model.User{
ID: "test-user",
Libraries: []model.Library{
{ID: 1, Name: "Library 1"},
{ID: 2, Name: "Library 2"},
{ID: 3, Name: "Library 3"},
},
}
ctx = request.WithUser(context.Background(), user)
})
It("should filter songs by specific library when musicFolderId is provided", func() {
mockMediaFileRepo.SetData(model.MediaFiles{
{ID: "1", Title: "Song 1"},
{ID: "2", Title: "Song 2"},
})
r := newGetRequest("count=2", "genre=rock", "musicFolderId=1")
r = r.WithContext(ctx)
resp, err := router.GetSongsByGenre(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.SongsByGenre.Songs).To(HaveLen(2))
// Verify that library filter was applied
query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
Expect(query).To(ContainSubstring("library_id IN (?)"))
Expect(args).To(ContainElement(1))
})
It("should filter songs by multiple libraries when multiple musicFolderId are provided", func() {
mockMediaFileRepo.SetData(model.MediaFiles{
{ID: "1", Title: "Song 1"},
{ID: "2", Title: "Song 2"},
})
r := newGetRequest("count=2", "genre=rock", "musicFolderId=1", "musicFolderId=2")
r = r.WithContext(ctx)
resp, err := router.GetSongsByGenre(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.SongsByGenre.Songs).To(HaveLen(2))
// Verify that library filter was applied
query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
Expect(query).To(ContainSubstring("library_id IN (?,?)"))
Expect(args).To(ContainElements(1, 2))
})
It("should return all accessible songs when no musicFolderId is provided", func() {
mockMediaFileRepo.SetData(model.MediaFiles{
{ID: "1", Title: "Song 1"},
{ID: "2", Title: "Song 2"},
})
r := newGetRequest("count=2", "genre=rock")
r = r.WithContext(ctx)
resp, err := router.GetSongsByGenre(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.SongsByGenre.Songs).To(HaveLen(2))
// Verify that library filter was applied
query, args, _ := mockMediaFileRepo.Options.Filters.ToSql()
Expect(query).To(ContainSubstring("library_id IN (?,?,?)"))
Expect(args).To(ContainElements(1, 2, 3))
})
})
})
Describe("GetStarred", func() {
var mockArtistRepo *tests.MockArtistRepo
var mockAlbumRepo *tests.MockAlbumRepo
var mockMediaFileRepo *tests.MockMediaFileRepo
BeforeEach(func() {
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
})
It("should return starred items", func() {
mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}})
mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}})
mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}})
r := newGetRequest()
resp, err := router.GetStarred(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Starred.Artist).To(HaveLen(1))
Expect(resp.Starred.Album).To(HaveLen(1))
Expect(resp.Starred.Song).To(HaveLen(1))
})
Context("with musicFolderId parameter", func() {
var user model.User
var ctx context.Context
BeforeEach(func() {
user = model.User{
ID: "test-user",
Libraries: []model.Library{
{ID: 1, Name: "Library 1"},
{ID: 2, Name: "Library 2"},
{ID: 3, Name: "Library 3"},
},
}
ctx = request.WithUser(context.Background(), user)
})
It("should filter starred items by specific library when musicFolderId is provided", func() {
mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}})
mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}})
mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}})
r := newGetRequest("musicFolderId=1")
r = r.WithContext(ctx)
resp, err := router.GetStarred(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Starred.Artist).To(HaveLen(1))
Expect(resp.Starred.Album).To(HaveLen(1))
Expect(resp.Starred.Song).To(HaveLen(1))
// Verify that library filter was applied to all types
artistQuery, artistArgs, _ := mockArtistRepo.Options.Filters.ToSql()
Expect(artistQuery).To(ContainSubstring("library_id IN (?)"))
Expect(artistArgs).To(ContainElement(1))
})
})
})
Describe("GetStarred2", func() {
var mockArtistRepo *tests.MockArtistRepo
var mockAlbumRepo *tests.MockAlbumRepo
var mockMediaFileRepo *tests.MockMediaFileRepo
BeforeEach(func() {
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
mockMediaFileRepo = ds.MediaFile(ctx).(*tests.MockMediaFileRepo)
})
It("should return starred items in ID3 format", func() {
mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}})
mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}})
mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}})
r := newGetRequest()
resp, err := router.GetStarred2(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Starred2.Artist).To(HaveLen(1))
Expect(resp.Starred2.Album).To(HaveLen(1))
Expect(resp.Starred2.Song).To(HaveLen(1))
})
Context("with musicFolderId parameter", func() {
var user model.User
var ctx context.Context
BeforeEach(func() {
user = model.User{
ID: "test-user",
Libraries: []model.Library{
{ID: 1, Name: "Library 1"},
{ID: 2, Name: "Library 2"},
{ID: 3, Name: "Library 3"},
},
}
ctx = request.WithUser(context.Background(), user)
})
It("should filter starred items by specific library when musicFolderId is provided", func() {
mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}})
mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}})
mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}})
r := newGetRequest("musicFolderId=1")
r = r.WithContext(ctx)
resp, err := router.GetStarred2(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Starred2.Artist).To(HaveLen(1))
Expect(resp.Starred2.Album).To(HaveLen(1))
Expect(resp.Starred2.Song).To(HaveLen(1))
// Verify that library filter was applied to all types
artistQuery, artistArgs, _ := mockArtistRepo.Options.Filters.ToSql()
Expect(artistQuery).To(ContainSubstring("library_id IN (?)"))
Expect(artistArgs).To(ContainElement(1))
})
})
}) })
}) })

View File

@@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/public"
@@ -17,7 +18,8 @@ import (
) )
func (api *Router) GetMusicFolders(r *http.Request) (*responses.Subsonic, error) { func (api *Router) GetMusicFolders(r *http.Request) (*responses.Subsonic, error) {
libraries, _ := api.ds.Library(r.Context()).GetAll() libraries := getUserAccessibleLibraries(r.Context())
folders := make([]responses.MusicFolder, len(libraries)) folders := make([]responses.MusicFolder, len(libraries))
for i, f := range libraries { for i, f := range libraries {
folders[i].Id = int32(f.ID) folders[i].Id = int32(f.ID)
@@ -28,28 +30,37 @@ func (api *Router) GetMusicFolders(r *http.Request) (*responses.Subsonic, error)
return response, nil return response, nil
} }
func (api *Router) getArtist(r *http.Request, libId int, ifModifiedSince time.Time) (model.ArtistIndexes, int64, error) { func (api *Router) getArtist(r *http.Request, libIds []int, ifModifiedSince time.Time) (model.ArtistIndexes, int64, error) {
ctx := r.Context() ctx := r.Context()
lib, err := api.ds.Library(ctx).Get(libId)
lastScanStr, err := api.ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "")
if err != nil { if err != nil {
log.Error(ctx, "Error retrieving Library", "id", libId, err) log.Error(ctx, "Error retrieving last scan start time", err)
return nil, 0, err return nil, 0, err
} }
lastScan := time.Now()
if lastScanStr != "" {
lastScan, err = time.Parse(time.RFC3339, lastScanStr)
}
var indexes model.ArtistIndexes var indexes model.ArtistIndexes
if lib.LastScanAt.After(ifModifiedSince) { if lastScan.After(ifModifiedSince) {
indexes, err = api.ds.Artist(ctx).GetIndex(false, model.RoleAlbumArtist) indexes, err = api.ds.Artist(ctx).GetIndex(false, libIds, model.RoleAlbumArtist)
if err != nil { if err != nil {
log.Error(ctx, "Error retrieving Indexes", err) log.Error(ctx, "Error retrieving Indexes", err)
return nil, 0, err return nil, 0, err
} }
if len(indexes) == 0 {
log.Debug(ctx, "No artists found in library", "libId", libIds)
return nil, 0, newError(responses.ErrorDataNotFound, "Library not found or empty")
}
} }
return indexes, lib.LastScanAt.UnixMilli(), err return indexes, lastScan.UnixMilli(), err
} }
func (api *Router) getArtistIndex(r *http.Request, libId int, ifModifiedSince time.Time) (*responses.Indexes, error) { func (api *Router) getArtistIndex(r *http.Request, libIds []int, ifModifiedSince time.Time) (*responses.Indexes, error) {
indexes, modified, err := api.getArtist(r, libId, ifModifiedSince) indexes, modified, err := api.getArtist(r, libIds, ifModifiedSince)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -67,8 +78,8 @@ func (api *Router) getArtistIndex(r *http.Request, libId int, ifModifiedSince ti
return res, nil return res, nil
} }
func (api *Router) getArtistIndexID3(r *http.Request, libId int, ifModifiedSince time.Time) (*responses.Artists, error) { func (api *Router) getArtistIndexID3(r *http.Request, libIds []int, ifModifiedSince time.Time) (*responses.Artists, error) {
indexes, modified, err := api.getArtist(r, libId, ifModifiedSince) indexes, modified, err := api.getArtist(r, libIds, ifModifiedSince)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -88,10 +99,10 @@ func (api *Router) getArtistIndexID3(r *http.Request, libId int, ifModifiedSince
func (api *Router) GetIndexes(r *http.Request) (*responses.Subsonic, error) { func (api *Router) GetIndexes(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r) p := req.Params(r)
musicFolderId := p.IntOr("musicFolderId", 1) musicFolderIds, _ := selectedMusicFolderIds(r, false)
ifModifiedSince := p.TimeOr("ifModifiedSince", time.Time{}) ifModifiedSince := p.TimeOr("ifModifiedSince", time.Time{})
res, err := api.getArtistIndex(r, musicFolderId, ifModifiedSince) res, err := api.getArtistIndex(r, musicFolderIds, ifModifiedSince)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -102,9 +113,9 @@ func (api *Router) GetIndexes(r *http.Request) (*responses.Subsonic, error) {
} }
func (api *Router) GetArtists(r *http.Request) (*responses.Subsonic, error) { func (api *Router) GetArtists(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r) musicFolderIds, _ := selectedMusicFolderIds(r, false)
musicFolderId := p.IntOr("musicFolderId", 1)
res, err := api.getArtistIndexID3(r, musicFolderId, time.Time{}) res, err := api.getArtistIndexID3(r, musicFolderIds, time.Time{})
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -0,0 +1,160 @@
package subsonic
import (
"context"
"fmt"
"net/http/httptest"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func contextWithUser(ctx context.Context, userID string, libraryIDs ...int) context.Context {
libraries := make([]model.Library, len(libraryIDs))
for i, id := range libraryIDs {
libraries[i] = model.Library{ID: id, Name: fmt.Sprintf("Test Library %d", id), Path: fmt.Sprintf("/music/library%d", id)}
}
user := model.User{
ID: userID,
Libraries: libraries,
}
return request.WithUser(ctx, user)
}
var _ = Describe("Browsing", func() {
var api *Router
var ctx context.Context
var ds model.DataStore
BeforeEach(func() {
ds = &tests.MockDataStore{}
auth.Init(ds)
api = &Router{ds: ds}
ctx = context.Background()
})
Describe("GetMusicFolders", func() {
It("should return all libraries the user has access", func() {
// Create mock user with libraries
ctx := contextWithUser(ctx, "user-id", 1, 2, 3)
// Create request
r := httptest.NewRequest("GET", "/rest/getMusicFolders", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.GetMusicFolders(r)
// Verify results
Expect(err).ToNot(HaveOccurred())
Expect(response).ToNot(BeNil())
Expect(response.MusicFolders).ToNot(BeNil())
Expect(response.MusicFolders.Folders).To(HaveLen(3))
Expect(response.MusicFolders.Folders[0].Name).To(Equal("Test Library 1"))
Expect(response.MusicFolders.Folders[1].Name).To(Equal("Test Library 2"))
Expect(response.MusicFolders.Folders[2].Name).To(Equal("Test Library 3"))
})
})
Describe("GetIndexes", func() {
It("should validate user access to the specified musicFolderId", func() {
// Create mock user with access to library 1 only
ctx = contextWithUser(ctx, "user-id", 1)
// Create request with musicFolderId=2 (not accessible)
r := httptest.NewRequest("GET", "/rest/getIndexes?musicFolderId=2", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.GetIndexes(r)
// Should return error due to lack of access
Expect(err).To(HaveOccurred())
Expect(response).To(BeNil())
})
It("should default to first accessible library when no musicFolderId specified", func() {
// Create mock user with access to libraries 2 and 3
ctx = contextWithUser(ctx, "user-id", 2, 3)
// Setup minimal mock library data for working tests
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
mockLibRepo.SetData(model.Libraries{
{ID: 2, Name: "Test Library 2", Path: "/music/library2"},
{ID: 3, Name: "Test Library 3", Path: "/music/library3"},
})
// Setup mock artist data
mockArtistRepo := ds.Artist(ctx).(*tests.MockArtistRepo)
mockArtistRepo.SetData(model.Artists{
{ID: "1", Name: "Test Artist 1"},
{ID: "2", Name: "Test Artist 2"},
})
// Create request without musicFolderId
r := httptest.NewRequest("GET", "/rest/getIndexes", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.GetIndexes(r)
// Should succeed and use first accessible library (2)
Expect(err).ToNot(HaveOccurred())
Expect(response).ToNot(BeNil())
Expect(response.Indexes).ToNot(BeNil())
})
})
Describe("GetArtists", func() {
It("should validate user access to the specified musicFolderId", func() {
// Create mock user with access to library 1 only
ctx = contextWithUser(ctx, "user-id", 1)
// Create request with musicFolderId=3 (not accessible)
r := httptest.NewRequest("GET", "/rest/getArtists?musicFolderId=3", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.GetArtists(r)
// Should return error due to lack of access
Expect(err).To(HaveOccurred())
Expect(response).To(BeNil())
})
It("should default to first accessible library when no musicFolderId specified", func() {
// Create mock user with access to libraries 1 and 2
ctx = contextWithUser(ctx, "user-id", 1, 2)
// Setup minimal mock library data for working tests
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
mockLibRepo.SetData(model.Libraries{
{ID: 1, Name: "Test Library 1", Path: "/music/library1"},
{ID: 2, Name: "Test Library 2", Path: "/music/library2"},
})
// Setup mock artist data
mockArtistRepo := ds.Artist(ctx).(*tests.MockArtistRepo)
mockArtistRepo.SetData(model.Artists{
{ID: "1", Name: "Test Artist 1"},
{ID: "2", Name: "Test Artist 2"},
})
// Create request without musicFolderId
r := httptest.NewRequest("GET", "/rest/getArtists", nil)
r = r.WithContext(ctx)
// Call endpoint
response, err := api.GetArtists(r)
// Should succeed and use first accessible library (1)
Expect(err).ToNot(HaveOccurred())
Expect(response).ToNot(BeNil())
Expect(response.Artist).ToNot(BeNil())
})
})
})

View File

@@ -123,6 +123,38 @@ func SongsByArtistTitleWithLyricsFirst(artist, title string) Options {
}) })
} }
func ApplyLibraryFilter(opts Options, musicFolderIds []int) Options {
if len(musicFolderIds) == 0 {
return opts
}
libraryFilter := Eq{"library_id": musicFolderIds}
if opts.Filters == nil {
opts.Filters = libraryFilter
} else {
opts.Filters = And{opts.Filters, libraryFilter}
}
return opts
}
// ApplyArtistLibraryFilter applies a filter to the given Options to ensure that only artists
// that are associated with the specified music folders are included in the results.
func ApplyArtistLibraryFilter(opts Options, musicFolderIds []int) Options {
if len(musicFolderIds) == 0 {
return opts
}
artistLibraryFilter := Eq{"library_artist.library_id": musicFolderIds}
if opts.Filters == nil {
opts.Filters = artistLibraryFilter
} else {
opts.Filters = And{opts.Filters, artistLibraryFilter}
}
return opts
}
func ByGenre(genre string) Options { func ByGenre(genre string) Options {
return addDefaultFilters(Options{ return addDefaultFilters(Options{
Sort: "name", Sort: "name",

View File

@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"mime" "mime"
"net/http" "net/http"
"slices"
"sort" "sort"
"strings" "strings"
@@ -17,6 +18,7 @@ import (
"github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/public"
"github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/number" "github.com/navidrome/navidrome/utils/number"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/slice"
) )
@@ -474,3 +476,40 @@ func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList) *responses
} }
return res return res
} }
// getUserAccessibleLibraries returns the list of libraries the current user has access to.
func getUserAccessibleLibraries(ctx context.Context) []model.Library {
user := getUser(ctx)
return user.Libraries
}
// selectedMusicFolderIds retrieves the music folder IDs from the request parameters.
// If no IDs are provided, it returns all libraries the user has access to (based on the user found in the context).
// If the parameter is required and not present, it returns an error.
// If any of the provided library IDs are invalid (don't exist or user doesn't have access), returns ErrorDataNotFound.
func selectedMusicFolderIds(r *http.Request, required bool) ([]int, error) {
p := req.Params(r)
musicFolderIds, err := p.Ints("musicFolderId")
// If the parameter is not present, it returns an error if it is required.
if errors.Is(err, req.ErrMissingParam) && required {
return nil, err
}
// Get user's accessible libraries for validation
libraries := getUserAccessibleLibraries(r.Context())
accessibleLibraryIds := slice.Map(libraries, func(lib model.Library) int { return lib.ID })
if len(musicFolderIds) > 0 {
// Validate all provided library IDs - if any are invalid, return an error
for _, id := range musicFolderIds {
if !slices.Contains(accessibleLibraryIds, id) {
return nil, newError(responses.ErrorDataNotFound, "Library %d not found or not accessible", id)
}
}
return musicFolderIds, nil
}
// If no musicFolderId is provided, return all libraries the user has access to.
return accessibleLibraryIds, nil
}

View File

@@ -1,10 +1,15 @@
package subsonic package subsonic
import ( import (
"context"
"net/http/httptest"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
) )
@@ -163,4 +168,108 @@ var _ = Describe("helpers", func() {
Expect(result).To(Equal(int32(4))) Expect(result).To(Equal(int32(4)))
}) })
}) })
Describe("selectedMusicFolderIds", func() {
var user model.User
var ctx context.Context
BeforeEach(func() {
user = model.User{
ID: "test-user",
Libraries: []model.Library{
{ID: 1, Name: "Library 1"},
{ID: 2, Name: "Library 2"},
{ID: 3, Name: "Library 3"},
},
}
ctx = request.WithUser(context.Background(), user)
})
Context("when musicFolderId parameter is provided", func() {
It("should return the specified musicFolderId values", func() {
r := httptest.NewRequest("GET", "/test?musicFolderId=1&musicFolderId=3", nil)
r = r.WithContext(ctx)
ids, err := selectedMusicFolderIds(r, false)
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(Equal([]int{1, 3}))
})
It("should ignore invalid musicFolderId parameter values", func() {
r := httptest.NewRequest("GET", "/test?musicFolderId=invalid&musicFolderId=2", nil)
r = r.WithContext(ctx)
ids, err := selectedMusicFolderIds(r, false)
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(Equal([]int{2})) // Only valid ID is returned
})
It("should return error when any library ID is not accessible", func() {
r := httptest.NewRequest("GET", "/test?musicFolderId=1&musicFolderId=5&musicFolderId=2&musicFolderId=99", nil)
r = r.WithContext(ctx)
ids, err := selectedMusicFolderIds(r, false)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Library 5 not found or not accessible"))
Expect(ids).To(BeNil())
})
})
Context("when musicFolderId parameter is not provided", func() {
Context("and required is false", func() {
It("should return all user's library IDs", func() {
r := httptest.NewRequest("GET", "/test", nil)
r = r.WithContext(ctx)
ids, err := selectedMusicFolderIds(r, false)
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(Equal([]int{1, 2, 3}))
})
It("should return empty slice when user has no libraries", func() {
userWithoutLibs := model.User{ID: "no-libs-user", Libraries: []model.Library{}}
ctxWithoutLibs := request.WithUser(context.Background(), userWithoutLibs)
r := httptest.NewRequest("GET", "/test", nil)
r = r.WithContext(ctxWithoutLibs)
ids, err := selectedMusicFolderIds(r, false)
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(Equal([]int{}))
})
})
Context("and required is true", func() {
It("should return ErrMissingParam error", func() {
r := httptest.NewRequest("GET", "/test", nil)
r = r.WithContext(ctx)
ids, err := selectedMusicFolderIds(r, true)
Expect(err).To(MatchError(req.ErrMissingParam))
Expect(ids).To(BeNil())
})
})
})
Context("when musicFolderId parameter is empty", func() {
It("should return all user's library IDs even when empty parameter is provided", func() {
r := httptest.NewRequest("GET", "/test?musicFolderId=", nil)
r = r.WithContext(ctx)
ids, err := selectedMusicFolderIds(r, false)
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(Equal([]int{1, 2, 3}))
})
})
Context("when all musicFolderId parameters are invalid", func() {
It("should return all user libraries when all musicFolderId parameters are invalid", func() {
r := httptest.NewRequest("GET", "/test?musicFolderId=invalid&musicFolderId=notanumber", nil)
r = r.WithContext(ctx)
ids, err := selectedMusicFolderIds(r, false)
Expect(err).ToNot(HaveOccurred())
Expect(ids).To(Equal([]int{1, 2, 3})) // Falls back to all user libraries
})
})
})
}) })

View File

@@ -138,4 +138,8 @@ func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) {
f.Events = append(f.Events, event) f.Events = append(f.Events, event)
} }
func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) {
f.Events = append(f.Events, event)
}
var _ events.Broker = (*fakeEventBroker)(nil) var _ events.Broker = (*fakeEventBroker)(nil)

View File

@@ -76,7 +76,7 @@ func (api *Router) create(ctx context.Context, playlistId, name string, ids []st
pls.OwnerID = owner.ID pls.OwnerID = owner.ID
} }
pls.Tracks = nil pls.Tracks = nil
pls.AddTracks(ids) pls.AddMediaFilesByID(ids)
err = tx.Playlist(ctx).Put(pls) err = tx.Playlist(ctx).Put(pls)
playlistId = pls.ID playlistId = pls.ID

View File

@@ -8,6 +8,7 @@ import (
"strings" "strings"
"time" "time"
. "github.com/Masterminds/squirrel"
"github.com/deluan/sanitize" "github.com/deluan/sanitize"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
@@ -41,9 +42,9 @@ func (api *Router) getSearchParams(r *http.Request) (*searchParams, error) {
return sp, nil return sp, nil
} }
type searchFunc[T any] func(q string, offset int, size int, includeMissing bool) (T, error) type searchFunc[T any] func(q string, offset int, size int, includeMissing bool, options ...model.QueryOptions) (T, error)
func callSearch[T any](ctx context.Context, s searchFunc[T], q string, offset, size int, result *T) func() error { func callSearch[T any](ctx context.Context, s searchFunc[T], q string, offset, size int, result *T, options ...model.QueryOptions) func() error {
return func() error { return func() error {
if size == 0 { if size == 0 {
return nil return nil
@@ -51,7 +52,7 @@ func callSearch[T any](ctx context.Context, s searchFunc[T], q string, offset, s
typ := strings.TrimPrefix(reflect.TypeOf(*result).String(), "model.") typ := strings.TrimPrefix(reflect.TypeOf(*result).String(), "model.")
var err error var err error
start := time.Now() start := time.Now()
*result, err = s(q, offset, size, false) *result, err = s(q, offset, size, false, options...)
if err != nil { if err != nil {
log.Error(ctx, "Error searching "+typ, "query", q, "elapsed", time.Since(start), err) log.Error(ctx, "Error searching "+typ, "query", q, "elapsed", time.Since(start), err)
} else { } else {
@@ -61,15 +62,23 @@ func callSearch[T any](ctx context.Context, s searchFunc[T], q string, offset, s
} }
} }
func (api *Router) searchAll(ctx context.Context, sp *searchParams) (mediaFiles model.MediaFiles, albums model.Albums, artists model.Artists) { func (api *Router) searchAll(ctx context.Context, sp *searchParams, musicFolderIds []int) (mediaFiles model.MediaFiles, albums model.Albums, artists model.Artists) {
start := time.Now() start := time.Now()
q := sanitize.Accents(strings.ToLower(strings.TrimSuffix(sp.query, "*"))) q := sanitize.Accents(strings.ToLower(strings.TrimSuffix(sp.query, "*")))
// Create query options for library filtering
var options []model.QueryOptions
if len(musicFolderIds) > 0 {
options = append(options, model.QueryOptions{
Filters: Eq{"library_id": musicFolderIds},
})
}
// Run searches in parallel // Run searches in parallel
g, ctx := errgroup.WithContext(ctx) g, ctx := errgroup.WithContext(ctx)
g.Go(callSearch(ctx, api.ds.MediaFile(ctx).Search, q, sp.songOffset, sp.songCount, &mediaFiles)) g.Go(callSearch(ctx, api.ds.MediaFile(ctx).Search, q, sp.songOffset, sp.songCount, &mediaFiles, options...))
g.Go(callSearch(ctx, api.ds.Album(ctx).Search, q, sp.albumOffset, sp.albumCount, &albums)) g.Go(callSearch(ctx, api.ds.Album(ctx).Search, q, sp.albumOffset, sp.albumCount, &albums, options...))
g.Go(callSearch(ctx, api.ds.Artist(ctx).Search, q, sp.artistOffset, sp.artistCount, &artists)) g.Go(callSearch(ctx, api.ds.Artist(ctx).Search, q, sp.artistOffset, sp.artistCount, &artists, options...))
err := g.Wait() err := g.Wait()
if err == nil { if err == nil {
log.Debug(ctx, fmt.Sprintf("Search resulted in %d songs, %d albums and %d artists", log.Debug(ctx, fmt.Sprintf("Search resulted in %d songs, %d albums and %d artists",
@@ -86,7 +95,13 @@ func (api *Router) Search2(r *http.Request) (*responses.Subsonic, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
mfs, als, as := api.searchAll(ctx, sp)
// Get optional library IDs from musicFolderId parameter
musicFolderIds, err := selectedMusicFolderIds(r, false)
if err != nil {
return nil, err
}
mfs, als, as := api.searchAll(ctx, sp, musicFolderIds)
response := newResponse() response := newResponse()
searchResult2 := &responses.SearchResult2{} searchResult2 := &responses.SearchResult2{}
@@ -115,7 +130,13 @@ func (api *Router) Search3(r *http.Request) (*responses.Subsonic, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
mfs, als, as := api.searchAll(ctx, sp)
// Get optional library IDs from musicFolderId parameter
musicFolderIds, err := selectedMusicFolderIds(r, false)
if err != nil {
return nil, err
}
mfs, als, as := api.searchAll(ctx, sp, musicFolderIds)
response := newResponse() response := newResponse()
searchResult3 := &responses.SearchResult3{} searchResult3 := &responses.SearchResult3{}

View File

@@ -0,0 +1,208 @@
package subsonic
import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Search", func() {
var router *Router
var ds model.DataStore
var mockAlbumRepo *tests.MockAlbumRepo
var mockArtistRepo *tests.MockArtistRepo
var mockMediaFileRepo *tests.MockMediaFileRepo
BeforeEach(func() {
ds = &tests.MockDataStore{}
auth.Init(ds)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
// Get references to the mock repositories so we can inspect their Options
mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo)
mockArtistRepo = ds.Artist(nil).(*tests.MockArtistRepo)
mockMediaFileRepo = ds.MediaFile(nil).(*tests.MockMediaFileRepo)
})
Context("musicFolderId parameter", func() {
assertQueryOptions := func(filter squirrel.Sqlizer, expectedQuery string, expectedArgs ...interface{}) {
GinkgoHelper()
query, args, err := filter.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(query).To(ContainSubstring(expectedQuery))
Expect(args).To(ContainElements(expectedArgs...))
}
Describe("Search2", func() {
It("should accept musicFolderId parameter", func() {
r := newGetRequest("query=test", "musicFolderId=1")
ctx := request.WithUser(r.Context(), model.User{
ID: "user1",
UserName: "testuser",
Libraries: []model.Library{{ID: 1, Name: "Library 1"}},
})
r = r.WithContext(ctx)
resp, err := router.Search2(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).ToNot(BeNil())
Expect(resp.SearchResult2).ToNot(BeNil())
// Verify that library filter was applied to all repositories
assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?)", 1)
assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1)
assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?)", 1)
})
It("should return results from all accessible libraries when musicFolderId is not provided", func() {
r := newGetRequest("query=test")
ctx := request.WithUser(r.Context(), model.User{
ID: "user1",
UserName: "testuser",
Libraries: []model.Library{
{ID: 1, Name: "Library 1"},
{ID: 2, Name: "Library 2"},
{ID: 3, Name: "Library 3"},
},
})
r = r.WithContext(ctx)
resp, err := router.Search2(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).ToNot(BeNil())
Expect(resp.SearchResult2).ToNot(BeNil())
// Verify that library filter was applied to all repositories with all accessible libraries
assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3)
assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3)
assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3)
})
It("should return empty results when user has no accessible libraries", func() {
r := newGetRequest("query=test")
ctx := request.WithUser(r.Context(), model.User{
ID: "user1",
UserName: "testuser",
Libraries: []model.Library{}, // No libraries
})
r = r.WithContext(ctx)
resp, err := router.Search2(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).ToNot(BeNil())
Expect(resp.SearchResult2).ToNot(BeNil())
Expect(mockAlbumRepo.Options.Filters).To(BeNil())
Expect(mockArtistRepo.Options.Filters).To(BeNil())
Expect(mockMediaFileRepo.Options.Filters).To(BeNil())
})
It("should return error for inaccessible musicFolderId", func() {
r := newGetRequest("query=test", "musicFolderId=999")
ctx := request.WithUser(r.Context(), model.User{
ID: "user1",
UserName: "testuser",
Libraries: []model.Library{{ID: 1, Name: "Library 1"}},
})
r = r.WithContext(ctx)
resp, err := router.Search2(r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Library 999 not found or not accessible"))
Expect(resp).To(BeNil())
})
})
Describe("Search3", func() {
It("should accept musicFolderId parameter", func() {
r := newGetRequest("query=test", "musicFolderId=1")
ctx := request.WithUser(r.Context(), model.User{
ID: "user1",
UserName: "testuser",
Libraries: []model.Library{{ID: 1, Name: "Library 1"}},
})
r = r.WithContext(ctx)
resp, err := router.Search3(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).ToNot(BeNil())
Expect(resp.SearchResult3).ToNot(BeNil())
// Verify that library filter was applied to all repositories
assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?)", 1)
assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1)
assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?)", 1)
})
It("should return results from all accessible libraries when musicFolderId is not provided", func() {
r := newGetRequest("query=test")
ctx := request.WithUser(r.Context(), model.User{
ID: "user1",
UserName: "testuser",
Libraries: []model.Library{
{ID: 1, Name: "Library 1"},
{ID: 2, Name: "Library 2"},
{ID: 3, Name: "Library 3"},
},
})
r = r.WithContext(ctx)
resp, err := router.Search3(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).ToNot(BeNil())
Expect(resp.SearchResult3).ToNot(BeNil())
// Verify that library filter was applied to all repositories with all accessible libraries
assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3)
assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3)
assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3)
})
It("should return empty results when user has no accessible libraries", func() {
r := newGetRequest("query=test")
ctx := request.WithUser(r.Context(), model.User{
ID: "user1",
UserName: "testuser",
Libraries: []model.Library{}, // No libraries
})
r = r.WithContext(ctx)
resp, err := router.Search3(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp).ToNot(BeNil())
Expect(resp.SearchResult3).ToNot(BeNil())
Expect(mockAlbumRepo.Options.Filters).To(BeNil())
Expect(mockArtistRepo.Options.Filters).To(BeNil())
Expect(mockMediaFileRepo.Options.Filters).To(BeNil())
})
It("should return error for inaccessible musicFolderId", func() {
// Test that the endpoint returns an error when user tries to access a library they don't have access to
r := newGetRequest("query=test", "musicFolderId=999")
ctx := request.WithUser(r.Context(), model.User{
ID: "user1",
UserName: "testuser",
Libraries: []model.Library{{ID: 1, Name: "Library 1"}},
})
r = r.WithContext(ctx)
resp, err := router.Search3(r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("Library 999 not found or not accessible"))
Expect(resp).To(BeNil())
})
})
})
})

View File

@@ -16,10 +16,11 @@ func CreateMockAlbumRepo() *MockAlbumRepo {
type MockAlbumRepo struct { type MockAlbumRepo struct {
model.AlbumRepository model.AlbumRepository
Data map[string]*model.Album Data map[string]*model.Album
All model.Albums All model.Albums
Err bool Err bool
Options model.QueryOptions Options model.QueryOptions
ReassignAnnotationCalls map[string]string // prevID -> newID
} }
func (m *MockAlbumRepo) SetError(err bool) { func (m *MockAlbumRepo) SetError(err bool) {
@@ -117,4 +118,44 @@ func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error {
return nil return nil
} }
func (m *MockAlbumRepo) Search(q string, offset int, size int, includeMissing bool, options ...model.QueryOptions) (model.Albums, error) {
if len(options) > 0 {
m.Options = options[0]
}
if m.Err {
return nil, errors.New("unexpected error")
}
// Simple mock implementation - just return all albums for testing
return m.All, nil
}
// ReassignAnnotation reassigns annotations from one album to another
func (m *MockAlbumRepo) ReassignAnnotation(prevID string, newID string) error {
if m.Err {
return errors.New("unexpected error")
}
// Mock implementation - track the reassignment calls
if m.ReassignAnnotationCalls == nil {
m.ReassignAnnotationCalls = make(map[string]string)
}
m.ReassignAnnotationCalls[prevID] = newID
return nil
}
// SetRating sets the rating for an album
func (m *MockAlbumRepo) SetRating(rating int, itemID string) error {
if m.Err {
return errors.New("unexpected error")
}
return nil
}
// SetStar sets the starred status for albums
func (m *MockAlbumRepo) SetStar(starred bool, itemIDs ...string) error {
if m.Err {
return errors.New("unexpected error")
}
return nil
}
var _ model.AlbumRepository = (*MockAlbumRepo)(nil) var _ model.AlbumRepository = (*MockAlbumRepo)(nil)

View File

@@ -16,8 +16,9 @@ func CreateMockArtistRepo() *MockArtistRepo {
type MockArtistRepo struct { type MockArtistRepo struct {
model.ArtistRepository model.ArtistRepository
Data map[string]*model.Artist Data map[string]*model.Artist
Err bool Err bool
Options model.QueryOptions
} }
func (m *MockArtistRepo) SetError(err bool) { func (m *MockArtistRepo) SetError(err bool) {
@@ -73,6 +74,9 @@ func (m *MockArtistRepo) IncPlayCount(id string, timestamp time.Time) error {
} }
func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) { func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
if len(options) > 0 {
m.Options = options[0]
}
if m.Err { if m.Err {
return nil, errors.New("mock repo error") return nil, errors.New("mock repo error")
} }
@@ -108,4 +112,49 @@ func (m *MockArtistRepo) RefreshPlayCounts() (int64, error) {
return int64(len(m.Data)), nil return int64(len(m.Data)), nil
} }
func (m *MockArtistRepo) GetIndex(includeMissing bool, libraryIds []int, roles ...model.Role) (model.ArtistIndexes, error) {
if m.Err {
return nil, errors.New("mock repo error")
}
artists, err := m.GetAll()
if err != nil {
return nil, err
}
// For mock purposes, if no artists available, return empty result
if len(artists) == 0 {
return model.ArtistIndexes{}, nil
}
// Simple index grouping by first letter (simplified implementation for mocks)
indexMap := make(map[string]model.Artists)
for _, artist := range artists {
key := "#"
if len(artist.Name) > 0 {
key = string(artist.Name[0])
}
indexMap[key] = append(indexMap[key], artist)
}
var result model.ArtistIndexes
for k, artists := range indexMap {
result = append(result, model.ArtistIndex{ID: k, Artists: artists})
}
return result, nil
}
func (m *MockArtistRepo) Search(q string, offset int, size int, includeMissing bool, options ...model.QueryOptions) (model.Artists, error) {
if len(options) > 0 {
m.Options = options[0]
}
if m.Err {
return nil, errors.New("unexpected error")
}
// Simple mock implementation - just return all artists for testing
allArtists, err := m.GetAll()
return allArtists, err
}
var _ model.ArtistRepository = (*MockArtistRepo)(nil) var _ model.ArtistRepository = (*MockArtistRepo)(nil)

View File

@@ -27,6 +27,7 @@ type MockDataStore struct {
MockedScrobbleBuffer model.ScrobbleBufferRepository MockedScrobbleBuffer model.ScrobbleBufferRepository
MockedRadio model.RadioRepository MockedRadio model.RadioRepository
scrobbleBufferMu sync.Mutex scrobbleBufferMu sync.Mutex
repoMu sync.Mutex
} }
func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository { func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository {
@@ -85,6 +86,8 @@ func (db *MockDataStore) Artist(ctx context.Context) model.ArtistRepository {
} }
func (db *MockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository { func (db *MockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository {
db.repoMu.Lock()
defer db.repoMu.Unlock()
if db.MockedMediaFile == nil { if db.MockedMediaFile == nil {
if db.RealDS != nil { if db.RealDS != nil {
db.MockedMediaFile = db.RealDS.MediaFile(ctx) db.MockedMediaFile = db.RealDS.MediaFile(ctx)

View File

@@ -1,14 +1,22 @@
package tests package tests
import ( import (
"context"
"errors"
"fmt"
"slices"
"strconv"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"golang.org/x/exp/maps"
) )
type MockLibraryRepo struct { type MockLibraryRepo struct {
model.LibraryRepository model.LibraryRepository
Data map[int]model.Library Data map[int]model.Library
Err error Err error
PutFn func(*model.Library) error // Allow custom Put behavior for testing
} }
func (m *MockLibraryRepo) SetData(data model.Libraries) { func (m *MockLibraryRepo) SetData(data model.Libraries) {
@@ -22,7 +30,54 @@ func (m *MockLibraryRepo) GetAll(...model.QueryOptions) (model.Libraries, error)
if m.Err != nil { if m.Err != nil {
return nil, m.Err return nil, m.Err
} }
return maps.Values(m.Data), nil var libraries model.Libraries
for _, lib := range m.Data {
libraries = append(libraries, lib)
}
// Sort by ID for predictable order
slices.SortFunc(libraries, func(a, b model.Library) int {
return a.ID - b.ID
})
return libraries, nil
}
func (m *MockLibraryRepo) CountAll(qo ...model.QueryOptions) (int64, error) {
if m.Err != nil {
return 0, m.Err
}
// If no query options, return total count
if len(qo) == 0 || qo[0].Filters == nil {
return int64(len(m.Data)), nil
}
// Handle squirrel.Eq filter for ID validation
if eq, ok := qo[0].Filters.(squirrel.Eq); ok {
if idFilter, exists := eq["id"]; exists {
if ids, isSlice := idFilter.([]int); isSlice {
count := 0
for _, id := range ids {
if _, exists := m.Data[id]; exists {
count++
}
}
return int64(count), nil
}
}
}
// Default to total count for other filters
return int64(len(m.Data)), nil
}
func (m *MockLibraryRepo) Get(id int) (*model.Library, error) {
if m.Err != nil {
return nil, m.Err
}
if lib, ok := m.Data[id]; ok {
return &lib, nil
}
return nil, model.ErrNotFound
} }
func (m *MockLibraryRepo) GetPath(id int) (string, error) { func (m *MockLibraryRepo) GetPath(id int) (string, error) {
@@ -35,8 +90,223 @@ func (m *MockLibraryRepo) GetPath(id int) (string, error) {
return "", model.ErrNotFound return "", model.ErrNotFound
} }
func (m *MockLibraryRepo) Put(library *model.Library) error {
if m.PutFn != nil {
return m.PutFn(library)
}
if m.Err != nil {
return m.Err
}
if m.Data == nil {
m.Data = make(map[int]model.Library)
}
m.Data[library.ID] = *library
return nil
}
func (m *MockLibraryRepo) Delete(id int) error {
if m.Err != nil {
return m.Err
}
if _, ok := m.Data[id]; !ok {
return model.ErrNotFound
}
delete(m.Data, id)
return nil
}
func (m *MockLibraryRepo) StoreMusicFolder() error {
if m.Err != nil {
return m.Err
}
return nil
}
func (m *MockLibraryRepo) AddArtist(id int, artistID string) error {
if m.Err != nil {
return m.Err
}
return nil
}
func (m *MockLibraryRepo) ScanBegin(id int, fullScan bool) error {
if m.Err != nil {
return m.Err
}
return nil
}
func (m *MockLibraryRepo) ScanEnd(id int) error {
if m.Err != nil {
return m.Err
}
return nil
}
func (m *MockLibraryRepo) ScanInProgress() (bool, error) {
if m.Err != nil {
return false, m.Err
}
return false, nil
}
func (m *MockLibraryRepo) RefreshStats(id int) error { func (m *MockLibraryRepo) RefreshStats(id int) error {
return nil return nil
} }
var _ model.LibraryRepository = &MockLibraryRepo{} // User-library association methods - mock implementations
func (m *MockLibraryRepo) GetUsersWithLibraryAccess(libraryID int) (model.Users, error) {
if m.Err != nil {
return nil, m.Err
}
// Mock: return empty users for now
return model.Users{}, nil
}
func (m *MockLibraryRepo) Count(options ...rest.QueryOptions) (int64, error) {
return m.CountAll()
}
func (m *MockLibraryRepo) Read(id string) (interface{}, error) {
idInt, _ := strconv.Atoi(id)
mf, err := m.Get(idInt)
if errors.Is(err, model.ErrNotFound) {
return nil, rest.ErrNotFound
}
return mf, err
}
func (m *MockLibraryRepo) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
return m.GetAll()
}
func (m *MockLibraryRepo) EntityName() string {
return "library"
}
func (m *MockLibraryRepo) NewInstance() interface{} {
return &model.Library{}
}
// REST Repository methods (string-based IDs)
func (m *MockLibraryRepo) Save(entity interface{}) (string, error) {
lib := entity.(*model.Library)
if m.Err != nil {
return "", m.Err
}
// Validate required fields
if lib.Name == "" {
return "", &rest.ValidationError{Errors: map[string]string{"name": "library name is required"}}
}
if lib.Path == "" {
return "", &rest.ValidationError{Errors: map[string]string{"path": "library path is required"}}
}
// Generate ID if not set
if lib.ID == 0 {
lib.ID = len(m.Data) + 1
}
if m.Data == nil {
m.Data = make(map[int]model.Library)
}
m.Data[lib.ID] = *lib
return strconv.Itoa(lib.ID), nil
}
func (m *MockLibraryRepo) Update(id string, entity interface{}, cols ...string) error {
lib := entity.(*model.Library)
if m.Err != nil {
return m.Err
}
// Validate required fields
if lib.Name == "" {
return &rest.ValidationError{Errors: map[string]string{"name": "library name is required"}}
}
if lib.Path == "" {
return &rest.ValidationError{Errors: map[string]string{"path": "library path is required"}}
}
idInt, err := strconv.Atoi(id)
if err != nil {
return errors.New("invalid ID format")
}
if _, exists := m.Data[idInt]; !exists {
return rest.ErrNotFound
}
lib.ID = idInt
m.Data[idInt] = *lib
return nil
}
func (m *MockLibraryRepo) DeleteByStringID(id string) error {
if m.Err != nil {
return m.Err
}
idInt, err := strconv.Atoi(id)
if err != nil {
return errors.New("invalid ID format")
}
if _, exists := m.Data[idInt]; !exists {
return rest.ErrNotFound
}
delete(m.Data, idInt)
return nil
}
// Service-level methods for core.Library interface
func (m *MockLibraryRepo) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) {
if m.Err != nil {
return nil, m.Err
}
if userID == "non-existent" {
return nil, model.ErrNotFound
}
// Convert map to slice for return
var libraries model.Libraries
for _, lib := range m.Data {
libraries = append(libraries, lib)
}
// Sort by ID for predictable order
slices.SortFunc(libraries, func(a, b model.Library) int {
return a.ID - b.ID
})
return libraries, nil
}
func (m *MockLibraryRepo) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error {
if m.Err != nil {
return m.Err
}
if userID == "non-existent" {
return model.ErrNotFound
}
if userID == "admin-1" {
return fmt.Errorf("%w: cannot manually assign libraries to admin users", model.ErrValidation)
}
if len(libraryIDs) == 0 {
return fmt.Errorf("%w: at least one library must be assigned to non-admin users", model.ErrValidation)
}
// Validate all library IDs exist
for _, id := range libraryIDs {
if _, exists := m.Data[id]; !exists {
return fmt.Errorf("%w: library ID %d does not exist", model.ErrValidation, id)
}
}
return nil
}
func (m *MockLibraryRepo) ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error {
if m.Err != nil {
return m.Err
}
// For testing purposes, allow access to all libraries
return nil
}
var _ model.LibraryRepository = (*MockLibraryRepo)(nil)
var _ model.ResourceRepository = (*MockLibraryRepo)(nil)

View File

@@ -27,6 +27,10 @@ type MockMediaFileRepo struct {
CountAllValue int64 CountAllValue int64
CountAllOptions model.QueryOptions CountAllOptions model.QueryOptions
DeleteAllMissingValue int64 DeleteAllMissingValue int64
Options model.QueryOptions
// Add fields for cross-library move detection tests
FindRecentFilesByMBZTrackIDFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error)
FindRecentFilesByPropertiesFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error)
} }
func (m *MockMediaFileRepo) SetError(err bool) { func (m *MockMediaFileRepo) SetError(err bool) {
@@ -72,7 +76,10 @@ func (m *MockMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, er
return nil, model.ErrNotFound return nil, model.ErrNotFound
} }
func (m *MockMediaFileRepo) GetAll(...model.QueryOptions) (model.MediaFiles, error) { func (m *MockMediaFileRepo) GetAll(qo ...model.QueryOptions) (model.MediaFiles, error) {
if len(qo) > 0 {
m.Options = qo[0]
}
if m.Err { if m.Err {
return nil, errors.New("error") return nil, errors.New("error")
} }
@@ -227,5 +234,66 @@ func (m *MockMediaFileRepo) NewInstance() interface{} {
return &model.MediaFile{} return &model.MediaFile{}
} }
func (m *MockMediaFileRepo) Search(q string, offset int, size int, includeMissing bool, options ...model.QueryOptions) (model.MediaFiles, error) {
if len(options) > 0 {
m.Options = options[0]
}
if m.Err {
return nil, errors.New("unexpected error")
}
// Simple mock implementation - just return all media files for testing
allFiles, err := m.GetAll()
return allFiles, err
}
// Cross-library move detection mock methods
func (m *MockMediaFileRepo) FindRecentFilesByMBZTrackID(missing model.MediaFile, since time.Time) (model.MediaFiles, error) {
if m.Err {
return nil, errors.New("error")
}
if m.FindRecentFilesByMBZTrackIDFunc != nil {
return m.FindRecentFilesByMBZTrackIDFunc(missing, since)
}
// Default implementation: find files with same MBZ Track ID in other libraries
var result model.MediaFiles
for _, mf := range m.Data {
if mf.LibraryID != missing.LibraryID &&
mf.MbzReleaseTrackID == missing.MbzReleaseTrackID &&
mf.MbzReleaseTrackID != "" &&
mf.Suffix == missing.Suffix &&
mf.CreatedAt.After(since) &&
!mf.Missing {
result = append(result, *mf)
}
}
return result, nil
}
func (m *MockMediaFileRepo) FindRecentFilesByProperties(missing model.MediaFile, since time.Time) (model.MediaFiles, error) {
if m.Err {
return nil, errors.New("error")
}
if m.FindRecentFilesByPropertiesFunc != nil {
return m.FindRecentFilesByPropertiesFunc(missing, since)
}
// Default implementation: find files with same properties in other libraries
var result model.MediaFiles
for _, mf := range m.Data {
if mf.LibraryID != missing.LibraryID &&
mf.Title == missing.Title &&
mf.Size == missing.Size &&
mf.Suffix == missing.Suffix &&
mf.DiscNumber == missing.DiscNumber &&
mf.TrackNumber == missing.TrackNumber &&
mf.Album == missing.Album &&
mf.MbzReleaseTrackID == "" && // Exclude files with MBZ Track ID
mf.CreatedAt.After(since) &&
!mf.Missing {
result = append(result, *mf)
}
}
return result, nil
}
var _ model.MediaFileRepository = (*MockMediaFileRepo)(nil) var _ model.MediaFileRepository = (*MockMediaFileRepo)(nil)
var _ model.ResourceRepository = (*MockMediaFileRepo)(nil) var _ model.ResourceRepository = (*MockMediaFileRepo)(nil)

View File

@@ -2,6 +2,7 @@ package tests
import ( import (
"encoding/base64" "encoding/base64"
"fmt"
"strings" "strings"
"time" "time"
@@ -11,14 +12,16 @@ import (
func CreateMockUserRepo() *MockedUserRepo { func CreateMockUserRepo() *MockedUserRepo {
return &MockedUserRepo{ return &MockedUserRepo{
Data: map[string]*model.User{}, Data: map[string]*model.User{},
UserLibraries: map[string][]int{},
} }
} }
type MockedUserRepo struct { type MockedUserRepo struct {
model.UserRepository model.UserRepository
Error error Error error
Data map[string]*model.User Data map[string]*model.User
UserLibraries map[string][]int // userID -> libraryIDs
} }
func (u *MockedUserRepo) CountAll(qo ...model.QueryOptions) (int64, error) { func (u *MockedUserRepo) CountAll(qo ...model.QueryOptions) (int64, error) {
@@ -55,6 +58,18 @@ func (u *MockedUserRepo) FindByUsernameWithPassword(username string) (*model.Use
return u.FindByUsername(username) return u.FindByUsername(username)
} }
func (u *MockedUserRepo) Get(id string) (*model.User, error) {
if u.Error != nil {
return nil, u.Error
}
for _, usr := range u.Data {
if usr.ID == id {
return usr, nil
}
}
return nil, model.ErrNotFound
}
func (u *MockedUserRepo) UpdateLastLoginAt(id string) error { func (u *MockedUserRepo) UpdateLastLoginAt(id string) error {
for _, usr := range u.Data { for _, usr := range u.Data {
if usr.ID == id { if usr.ID == id {
@@ -74,3 +89,37 @@ func (u *MockedUserRepo) UpdateLastAccessAt(id string) error {
} }
return u.Error return u.Error
} }
// Library association methods - mock implementations
func (u *MockedUserRepo) GetUserLibraries(userID string) (model.Libraries, error) {
if u.Error != nil {
return nil, u.Error
}
libraryIDs, exists := u.UserLibraries[userID]
if !exists {
return model.Libraries{}, nil
}
// Mock: Create libraries based on IDs
var libraries model.Libraries
for _, id := range libraryIDs {
libraries = append(libraries, model.Library{
ID: id,
Name: fmt.Sprintf("Test Library %d", id),
Path: fmt.Sprintf("/music/library%d", id),
})
}
return libraries, nil
}
func (u *MockedUserRepo) SetUserLibraries(userID string, libraryIDs []int) error {
if u.Error != nil {
return u.Error
}
if u.UserLibraries == nil {
u.UserLibraries = make(map[string][]int)
}
u.UserLibraries[userID] = libraryIDs
return nil
}

View File

@@ -15,9 +15,11 @@ import artist from './artist'
import playlist from './playlist' import playlist from './playlist'
import radio from './radio' import radio from './radio'
import share from './share' import share from './share'
import library from './library'
import { Player } from './audioplayer' import { Player } from './audioplayer'
import customRoutes from './routes' import customRoutes from './routes'
import { import {
libraryReducer,
themeReducer, themeReducer,
addToPlaylistDialogReducer, addToPlaylistDialogReducer,
expandInfoDialogReducer, expandInfoDialogReducer,
@@ -56,6 +58,7 @@ const adminStore = createAdminStore({
dataProvider, dataProvider,
history, history,
customReducers: { customReducers: {
library: libraryReducer,
player: playerReducer, player: playerReducer,
albumView: albumViewReducer, albumView: albumViewReducer,
theme: themeReducer, theme: themeReducer,
@@ -122,7 +125,13 @@ const Admin = (props) => {
) : ( ) : (
<Resource name="transcoding" /> <Resource name="transcoding" />
), ),
permissions === 'admin' ? (
<Resource
name="library"
{...library}
options={{ subMenu: 'settings' }}
/>
) : null,
permissions === 'admin' ? ( permissions === 'admin' ? (
<Resource <Resource
name="missing" name="missing"

View File

@@ -1,3 +1,4 @@
export * from './library'
export * from './player' export * from './player'
export * from './themes' export * from './themes'
export * from './albumView' 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

@@ -38,6 +38,7 @@ const AlbumInfo = (props) => {
const record = useRecordContext(props) const record = useRecordContext(props)
const data = { const data = {
album: <TextField source={'name'} />, album: <TextField source={'name'} />,
libraryName: <TextField source="libraryName" />,
albumArtist: ( albumArtist: (
<ArtistLinkField source="albumArtist" record={record} limit={Infinity} /> <ArtistLinkField source="albumArtist" record={record} limit={Infinity} />
), ),

View File

@@ -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 (
<Box className={classes.root}>
<Chip
icon={<LibraryMusic />}
label={displayText}
onClick={handleToggle}
onDelete={open ? handleToggle : undefined}
deleteIcon={open ? <ExpandLess /> : <ExpandMore />}
variant="outlined"
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,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 }) => (
<div className={className} {...props}>
{children}
</div>
),
Chip: ({ label, onClick, onDelete, deleteIcon, icon, ...props }) => (
<button onClick={onClick} {...props}>
{icon}
{label}
{deleteIcon && <span onClick={onDelete}>{deleteIcon}</span>}
</button>
),
ClickAwayListener: ({ children, onClickAway }) => (
<div data-testid="click-away-listener" onMouseDown={onClickAway}>
{children}
</div>
),
Collapse: ({ children, in: inProp }) =>
inProp ? <div>{children}</div> : null,
FormControl: ({ children }) => <div>{children}</div>,
FormGroup: ({ children }) => <div>{children}</div>,
FormControlLabel: ({ control, label }) => (
<label>
{control}
{label}
</label>
),
Checkbox: ({
checked,
indeterminate,
onChange,
size,
className,
...props
}) => (
<input
type="checkbox"
checked={checked}
ref={(el) => {
if (el) el.indeterminate = indeterminate
}}
onChange={onChange}
className={className}
{...props}
/>
),
Typography: ({ children, variant, ...props }) => (
<span {...props}>{children}</span>
),
Paper: ({ children, className }) => (
<div className={className}>{children}</div>
),
Popper: ({ open, children, anchorEl, placement, className }) =>
open ? (
<div className={className} data-testid="popper">
{children}
</div>
) : 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: () => <span data-testid="expand-more"></span>,
ExpandLess: () => <span data-testid="expand-less"></span>,
LibraryMusic: () => <span data-testid="library-music">🎵</span>,
}))
// 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(<LibrarySelector />)
}
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()
})
})
})

View File

@@ -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 (
<div className={classes.emptyMessage}>
<Typography variant="body2">No libraries available</Typography>
</div>
)
}
const LibraryListItem = ({ library, isSelected, onToggle }) => {
const classes = useStyles()
return (
<ListItem
className={classes.listItem}
button
onClick={() => onToggle(library)}
dense
>
<ListItemIcon>
<Checkbox
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
checkedIcon={<CheckBoxIcon fontSize="small" />}
checked={isSelected}
tabIndex={-1}
disableRipple
/>
</ListItemIcon>
<ListItemText primary={library.name} />
</ListItem>
)
}
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 (
<div className={classes.root}>
{options.length > 1 && (
<Box className={classes.headerContainer}>
<Checkbox
checked={isAllSelected}
indeterminate={isIndeterminate}
onChange={handleMasterCheckboxChange}
size="small"
className={classes.masterCheckbox}
/>
<Typography variant="body2">
{translate('resources.user.message.selectAllLibraries')}
</Typography>
</Box>
)}
<List className={classes.libraryList}>
{options.length === 0 ? (
<EmptyLibraryMessage />
) : (
options.map((library) => (
<LibraryListItem
key={library.id}
library={library}
isSelected={isLibrarySelected(library)}
onToggle={handleLibraryToggle}
/>
))
)}
</List>
</div>
)
}
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,
}

View File

@@ -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 }) => <div>{children}</div>,
ListItem: ({ children, button, onClick, dense, className }) => (
<button onClick={onClick} className={className}>
{children}
</button>
),
ListItemIcon: ({ children }) => <span>{children}</span>,
ListItemText: ({ primary }) => <span>{primary}</span>,
Typography: ({ children, variant }) => <span>{children}</span>,
Box: ({ children, className }) => <div className={className}>{children}</div>,
Checkbox: ({
checked,
indeterminate,
onChange,
size,
className,
...props
}) => (
<input
type="checkbox"
checked={checked}
ref={(el) => {
if (el) el.indeterminate = indeterminate
}}
onChange={onChange}
className={className}
{...props}
/>
),
makeStyles: () => () => ({}),
}))
// Mock Material-UI icons
vi.mock('@material-ui/icons', () => ({
CheckBox: () => <span></span>,
CheckBoxOutlineBlank: () => <span></span>,
}))
// Mock the react-admin hook
vi.mock('react-admin', () => ({
useGetList: vi.fn(),
useTranslate: vi.fn(() => (key) => key), // Simple translation mock
}))
describe('<SelectLibraryInput />', () => {
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(<SelectLibraryInput onChange={mockOnChange} value={[]} />)
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(<SelectLibraryInput onChange={mockOnChange} value={[]} />)
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(<SelectLibraryInput onChange={mockOnChange} value={[]} />)
// 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(<SelectLibraryInput onChange={mockOnChange} value={['1']} />)
// 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(<SelectLibraryInput onChange={mockOnChange} value={['1']} />)
// 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(<SelectLibraryInput onChange={mockOnChange} value={[{ id: '2' }]} />)
// 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(<SelectLibraryInput onChange={mockOnChange} value={[]} />)
// 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(<SelectLibraryInput onChange={mockOnChange} value={[]} />)
// 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(<SelectLibraryInput onChange={mockOnChange} value={[]} />)
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(<SelectLibraryInput onChange={mockOnChange} value={['1', '2']} />)
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(<SelectLibraryInput onChange={mockOnChange} value={['1']} />)
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(
<SelectLibraryInput
onChange={mockOnChange}
value={[]}
isNewUser={true}
/>,
)
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(
<SelectLibraryInput
onChange={mockOnChange}
value={['2']} // Already has a value
isNewUser={true}
/>,
)
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(
<SelectLibraryInput
onChange={mockOnChange}
value={[]}
isNewUser={true}
/>,
)
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(
<SelectLibraryInput
onChange={mockOnChange}
value={[]}
isNewUser={true}
/>,
)
expect(mockOnChange).not.toHaveBeenCalled()
})
it('should reset initialization state when isNewUser prop changes', () => {
setupMockLibraries([
{ id: '1', name: 'Library 1', defaultNewUsers: true },
])
const { rerender } = render(
<SelectLibraryInput
onChange={mockOnChange}
value={[]}
isNewUser={false} // Start as existing user
/>,
)
expect(mockOnChange).not.toHaveBeenCalled()
// Change to new user
rerender(
<SelectLibraryInput
onChange={mockOnChange}
value={[]}
isNewUser={true}
/>,
)
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(
<SelectLibraryInput
onChange={mockOnChange}
value={[]}
isNewUser={true}
/>,
)
expect(mockOnChange).toHaveBeenCalledWith(['1'])
mockOnChange.mockClear()
// Re-render with empty value prop (simulating form state update)
rerender(
<SelectLibraryInput
onChange={mockOnChange}
value={[]} // Still empty
isNewUser={true}
/>,
)
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(
<SelectLibraryInput
onChange={mockOnChange}
value={[]} // Empty value for existing user
isNewUser={false}
/>,
)
// 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(
<SelectLibraryInput
onChange={mockOnChange}
value={[]}
isNewUser={true}
/>,
)
expect(mockOnChange).toHaveBeenCalledWith(['1'])
})
})
})

View File

@@ -59,6 +59,7 @@ export const SongInfo = (props) => {
] ]
const data = { const data = {
path: <PathField />, path: <PathField />,
libraryName: <TextField source="libraryName" />,
album: ( album: (
<AlbumLinkField source="album" sortByOrder={'ASC'} record={record} /> <AlbumLinkField source="album" sortByOrder={'ASC'} record={record} />
), ),

View File

@@ -27,6 +27,7 @@ export * from './useAlbumsPerPage'
export * from './useGetHandleArtistClick' export * from './useGetHandleArtistClick'
export * from './useInterval' export * from './useInterval'
export * from './useResourceRefresh' export * from './useResourceRefresh'
export * from './useRefreshOnEvents'
export * from './useToggleLove' export * from './useToggleLove'
export * from './useTraceUpdate' export * from './useTraceUpdate'
export * from './Writable' export * from './Writable'

Some files were not shown because too many files have changed in this diff Show More