mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-23 23:18:05 -05:00
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:
53
.github/copilot-instructions.md
vendored
53
.github/copilot-instructions.md
vendored
@@ -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
|
||||
@@ -110,7 +110,7 @@ func mainContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
func startServer(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
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("Public Endpoints", consts.URLPathPublic, CreatePublicRouter())
|
||||
if conf.Server.LastFM.Enabled {
|
||||
|
||||
@@ -52,13 +52,25 @@ func CreateServer() *server.Server {
|
||||
return serverServer
|
||||
}
|
||||
|
||||
func CreateNativeAPIRouter() *nativeapi.Router {
|
||||
func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
share := core.NewShare(dataStore)
|
||||
playlists := core.NewPlaylists(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
|
||||
}
|
||||
|
||||
@@ -164,7 +176,7 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
||||
broker := events.GetBroker()
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
watcher := scanner.NewWatcher(dataStore, scannerScanner)
|
||||
watcher := scanner.GetWatcher(dataStore, scannerScanner)
|
||||
return watcher
|
||||
}
|
||||
|
||||
@@ -185,7 +197,7 @@ func getPluginManager() plugins.Manager {
|
||||
|
||||
// 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 {
|
||||
manager := getPluginManager()
|
||||
|
||||
@@ -38,12 +38,14 @@ var allProviders = wire.NewSet(
|
||||
listenbrainz.NewRouter,
|
||||
events.GetBroker,
|
||||
scanner.New,
|
||||
scanner.NewWatcher,
|
||||
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 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(
|
||||
allProviders,
|
||||
))
|
||||
|
||||
@@ -96,8 +96,11 @@ func (a *cacheWarmer) run(ctx context.Context) {
|
||||
|
||||
// If cache not available, keep waiting
|
||||
if !a.cache.Available(ctx) {
|
||||
if len(a.buffer) > 0 {
|
||||
log.Trace(ctx, "Cache not available, buffering precache request", "bufferLen", len(a.buffer))
|
||||
a.mutex.Lock()
|
||||
bufferLen := len(a.buffer)
|
||||
a.mutex.Unlock()
|
||||
if bufferLen > 0 {
|
||||
log.Trace(ctx, "Cache not available, buffering precache request", "bufferLen", bufferLen)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ var _ = Describe("CacheWarmer", 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.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-2"))
|
||||
@@ -214,3 +215,7 @@ func (f *mockFileCache) SetDisabled(v bool) {
|
||||
f.disabled.Store(v)
|
||||
f.ready.Store(true)
|
||||
}
|
||||
|
||||
func (f *mockFileCache) SetReady(v bool) {
|
||||
f.ready.Store(v)
|
||||
}
|
||||
|
||||
412
core/library.go
Normal file
412
core/library.go
Normal 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
980
core/library_test.go
Normal 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)
|
||||
}
|
||||
46
core/mock_library_service.go
Normal file
46
core/mock_library_service.go
Normal 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)
|
||||
@@ -326,7 +326,7 @@ func (s *playlists) Update(ctx context.Context, playlistID string,
|
||||
if needsTrackRefresh {
|
||||
pls, err = repo.GetWithTracks(playlistID, true, false)
|
||||
pls.RemoveTracks(idxToRemove)
|
||||
pls.AddTracks(idsToAdd)
|
||||
pls.AddMediaFilesByID(idsToAdd)
|
||||
} else {
|
||||
if len(idsToAdd) > 0 {
|
||||
_, err = tracks.Add(idsToAdd)
|
||||
|
||||
@@ -74,8 +74,7 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
|
||||
}
|
||||
if conf.Server.EnableNowPlaying {
|
||||
m.OnExpiration(func(_ string, _ NowPlayingInfo) {
|
||||
ctx := events.BroadcastToAll(context.Background())
|
||||
broker.SendMessage(ctx, &events.NowPlayingCount{Count: m.Len()})
|
||||
broker.SendBroadcastMessage(context.Background(), &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
|
||||
_ = p.playMap.AddWithTTL(playerId, info, ttl)
|
||||
if conf.Server.EnableNowPlaying {
|
||||
ctx = events.BroadcastToAll(ctx)
|
||||
p.broker.SendMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
|
||||
p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()})
|
||||
}
|
||||
player, _ := request.PlayerFrom(ctx)
|
||||
if player.ScrobbleEnabled {
|
||||
|
||||
@@ -429,6 +429,12 @@ func (f *fakeEventBroker) SendMessage(_ context.Context, event 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 {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
@@ -17,6 +17,7 @@ var Set = wire.NewSet(
|
||||
NewPlayers,
|
||||
NewShare,
|
||||
NewPlaylists,
|
||||
NewLibrary,
|
||||
agents.GetAgents,
|
||||
external.NewProvider,
|
||||
wire.Bind(new(external.Agents), new(*agents.Agents)),
|
||||
|
||||
119
db/migrations/20250701010108_add_multi_library_support.go
Normal file
119
db/migrations/20250701010108_add_multi_library_support.go
Normal 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
|
||||
}
|
||||
@@ -14,6 +14,8 @@ type Album struct {
|
||||
|
||||
ID string `structs:"id" json:"id"`
|
||||
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"`
|
||||
EmbedArtPath string `structs:"embed_art_path" json:"-"`
|
||||
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated, use Participants
|
||||
|
||||
@@ -78,7 +78,7 @@ type ArtistRepository interface {
|
||||
UpdateExternalInfo(a *Artist) error
|
||||
Get(id string) (*Artist, 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:
|
||||
RefreshPlayCounts() (int64, error)
|
||||
|
||||
@@ -53,6 +53,7 @@ var fieldMap = map[string]*mappedField{
|
||||
"mbz_recording_id": {field: "media_file.mbz_recording_id"},
|
||||
"mbz_release_track_id": {field: "media_file.mbz_release_track_id"},
|
||||
"mbz_release_group_id": {field: "media_file.mbz_release_group_id"},
|
||||
"library_id": {field: "media_file.library_id", numeric: true},
|
||||
|
||||
// special fields
|
||||
"random": {field: "", order: "random()"}, // pseudo-field for random sorting
|
||||
|
||||
@@ -29,7 +29,11 @@ var _ = Describe("Operators", func() {
|
||||
},
|
||||
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 [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 [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("lt", Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10),
|
||||
Entry("contains", Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"),
|
||||
|
||||
@@ -8,4 +8,5 @@ var (
|
||||
ErrNotAuthorized = errors.New("not authorized")
|
||||
ErrExpired = errors.New("access expired")
|
||||
ErrNotAvailable = errors.New("functionality not available")
|
||||
ErrValidation = errors.New("validation error")
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
type Folder struct {
|
||||
ID string `structs:"id"`
|
||||
LibraryID int `structs:"library_id"`
|
||||
LibraryPath string `structs:"-" json:"-" hash:"-"`
|
||||
LibraryPath string `structs:"-" json:"-" hash:"ignore"`
|
||||
Path string `structs:"path"`
|
||||
Name string `structs:"name"`
|
||||
ParentID string `structs:"parent_id"`
|
||||
|
||||
@@ -2,40 +2,57 @@ package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
type Library struct {
|
||||
ID int
|
||||
Name string
|
||||
Path string
|
||||
RemotePath string
|
||||
LastScanAt time.Time
|
||||
LastScanStartedAt time.Time
|
||||
FullScanInProgress bool
|
||||
UpdatedAt time.Time
|
||||
CreatedAt time.Time
|
||||
|
||||
TotalSongs int
|
||||
TotalAlbums int
|
||||
TotalArtists int
|
||||
TotalFolders int
|
||||
TotalFiles int
|
||||
TotalMissingFiles int
|
||||
TotalSize int64
|
||||
ID int `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Path string `json:"path" db:"path"`
|
||||
RemotePath string `json:"remotePath" db:"remote_path"`
|
||||
LastScanAt time.Time `json:"lastScanAt" db:"last_scan_at"`
|
||||
LastScanStartedAt time.Time `json:"lastScanStartedAt" db:"last_scan_started_at"`
|
||||
FullScanInProgress bool `json:"fullScanInProgress" db:"full_scan_in_progress"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
TotalSongs int `json:"totalSongs" db:"total_songs"`
|
||||
TotalAlbums int `json:"totalAlbums" db:"total_albums"`
|
||||
TotalArtists int `json:"totalArtists" db:"total_artists"`
|
||||
TotalFolders int `json:"totalFolders" db:"total_folders"`
|
||||
TotalFiles int `json:"totalFiles" db:"total_files"`
|
||||
TotalMissingFiles int `json:"totalMissingFiles" db:"total_missing_files"`
|
||||
TotalSize int64 `json:"totalSize" db:"total_size"`
|
||||
TotalDuration float64 `json:"totalDuration" db:"total_duration"`
|
||||
DefaultNewUsers bool `json:"defaultNewUsers" db:"default_new_users"`
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultLibraryID = 1
|
||||
DefaultLibraryName = "Music Library"
|
||||
)
|
||||
|
||||
type Libraries []Library
|
||||
|
||||
func (l Libraries) IDs() []int {
|
||||
return slice.Map(l, func(lib Library) int { return lib.ID })
|
||||
}
|
||||
|
||||
type LibraryRepository interface {
|
||||
Get(id int) (*Library, error)
|
||||
// GetPath returns the path of the library with the given ID.
|
||||
// Its implementation must be optimized to avoid unnecessary queries.
|
||||
GetPath(id int) (string, error)
|
||||
GetAll(...QueryOptions) (Libraries, error)
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
Put(*Library) error
|
||||
Delete(id int) error
|
||||
StoreMusicFolder() 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
|
||||
ScanBegin(id int, fullScan bool) error
|
||||
ScanEnd(id int) error
|
||||
|
||||
@@ -26,7 +26,8 @@ type MediaFile struct {
|
||||
ID string `structs:"id" json:"id" hash:"ignore"`
|
||||
PID string `structs:"pid" json:"-" 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"`
|
||||
Path string `structs:"path" json:"path" hash:"ignore"`
|
||||
Title string `structs:"title" json:"title"`
|
||||
@@ -367,6 +368,8 @@ type MediaFileRepository interface {
|
||||
MarkMissing(bool, ...*MediaFile) error
|
||||
MarkMissingByFolder(missing bool, folderIDs ...string) error
|
||||
GetMissingAndMatching(libId int) (MediaFileCursor, error)
|
||||
FindRecentFilesByMBZTrackID(missing MediaFile, since time.Time) (MediaFiles, error)
|
||||
FindRecentFilesByProperties(missing MediaFile, since time.Time) (MediaFiles, error)
|
||||
|
||||
AnnotatedRepository
|
||||
BookmarkableRepository
|
||||
|
||||
@@ -14,11 +14,15 @@ import (
|
||||
// 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.
|
||||
|
||||
func legacyTrackID(mf model.MediaFile) string {
|
||||
return fmt.Sprintf("%x", md5.Sum([]byte(mf.Path)))
|
||||
func legacyTrackID(mf model.MediaFile, prependLibId bool) string {
|
||||
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)
|
||||
albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", legacyMapAlbumArtistName(md), legacyMapAlbumName(md)))
|
||||
if !conf.Server.Scanner.GroupAlbumReleases {
|
||||
@@ -26,6 +30,9 @@ func legacyAlbumID(md Metadata) string {
|
||||
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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
@@ -77,7 +77,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
|
||||
|
||||
// Persistent IDs
|
||||
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 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 {
|
||||
getPID := createGetPID(id.NewHash)
|
||||
return getPID(mf, md, pidConf)
|
||||
return md.albumID(mf, pidConf)
|
||||
}
|
||||
|
||||
func (md Metadata) mapGain(rg, r128 model.TagName) *float64 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package metadata
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -21,13 +22,15 @@ type hashFunc = func(...string) string
|
||||
// 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.
|
||||
// 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 {
|
||||
var getPID func(mf model.MediaFile, md Metadata, spec string) string
|
||||
getAttr := func(mf model.MediaFile, md Metadata, attr string) string {
|
||||
type getPIDFunc = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) 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))
|
||||
switch attr {
|
||||
case "albumid":
|
||||
return getPID(mf, md, conf.Server.PID.Album)
|
||||
return getPID(mf, md, conf.Server.PID.Album, prependLibId)
|
||||
case "folder":
|
||||
return filepath.Dir(mf.Path)
|
||||
case "albumartistid":
|
||||
@@ -39,14 +42,14 @@ func createGetPID(hash hashFunc) func(mf model.MediaFile, md Metadata, spec stri
|
||||
}
|
||||
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 := ""
|
||||
fields := strings.Split(spec, "|")
|
||||
for _, field := range fields {
|
||||
attributes := strings.Split(field, ",")
|
||||
hasValue := false
|
||||
values := slice.Map(attributes, func(attr string) string {
|
||||
v := getAttr(mf, md, attr)
|
||||
v := getAttr(mf, md, attr, prependLibId)
|
||||
if v != "" {
|
||||
hasValue = true
|
||||
}
|
||||
@@ -57,32 +60,35 @@ func createGetPID(hash hashFunc) func(mf model.MediaFile, md Metadata, spec stri
|
||||
break
|
||||
}
|
||||
}
|
||||
if prependLibId {
|
||||
pid = fmt.Sprintf("%d\\%s", mf.LibraryID, 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 {
|
||||
case "track_legacy":
|
||||
return legacyTrackID(mf)
|
||||
return legacyTrackID(mf, prependLibId)
|
||||
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 {
|
||||
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 {
|
||||
return createGetPID(id.NewHash)(mf, md, conf.Server.PID.Album)
|
||||
func (md Metadata) albumID(mf model.MediaFile, pidConf string) string {
|
||||
return createGetPID(id.NewHash)(mf, md, pidConf, true)
|
||||
}
|
||||
|
||||
// BFR Must be configurable?
|
||||
func (md Metadata) artistID(name string) string {
|
||||
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 {
|
||||
|
||||
@@ -15,7 +15,7 @@ var _ = Describe("getPID", func() {
|
||||
md Metadata
|
||||
mf model.MediaFile
|
||||
sum hashFunc
|
||||
getPID func(mf model.MediaFile, md Metadata, spec string) string
|
||||
getPID getPIDFunc
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
@@ -28,7 +28,7 @@ var _ = Describe("getPID", func() {
|
||||
When("no attributes were present", func() {
|
||||
It("should return empty pid", func() {
|
||||
md.tags = map[model.TagName][]string{}
|
||||
pid := getPID(mf, md, spec)
|
||||
pid := getPID(mf, md, spec, false)
|
||||
Expect(pid).To(Equal("()"))
|
||||
})
|
||||
})
|
||||
@@ -40,7 +40,7 @@ var _ = Describe("getPID", func() {
|
||||
"discnumber": {"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() {
|
||||
@@ -48,7 +48,7 @@ var _ = Describe("getPID", func() {
|
||||
md.tags = map[model.TagName][]string{
|
||||
"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() {
|
||||
@@ -57,7 +57,7 @@ var _ = Describe("getPID", func() {
|
||||
"album": {"album name"},
|
||||
"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.filePath = "/path/to/file.mp3"
|
||||
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() {
|
||||
@@ -81,7 +81,7 @@ var _ = Describe("getPID", func() {
|
||||
spec := "folder|title"
|
||||
md.tags = map[model.TagName][]string{"title": {"title"}}
|
||||
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() {
|
||||
@@ -94,7 +94,7 @@ var _ = Describe("getPID", func() {
|
||||
"releasedate": {"2021-01-01"},
|
||||
}
|
||||
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() {
|
||||
@@ -104,14 +104,14 @@ var _ = Describe("getPID", func() {
|
||||
"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() {
|
||||
It("should return the pid", func() {
|
||||
spec := "album|title"
|
||||
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{
|
||||
"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() {
|
||||
@@ -133,7 +133,7 @@ var _ = Describe("getPID", func() {
|
||||
"albumartist": {"Album Artist"},
|
||||
"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() {
|
||||
@@ -143,7 +143,129 @@ var _ = Describe("getPID", func() {
|
||||
"albumartist": {"Album Artist"},
|
||||
"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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,6 +40,21 @@ func (pls Playlist) MediaFiles() 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) {
|
||||
var newTracks PlaylistTracks
|
||||
for i, t := range pls.Tracks {
|
||||
@@ -49,6 +64,7 @@ func (pls *Playlist) RemoveTracks(idxToRemove []int) {
|
||||
newTracks = append(newTracks, t)
|
||||
}
|
||||
pls.Tracks = newTracks
|
||||
pls.refreshStats()
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
func (pls *Playlist) AddTracks(mediaFileIds []string) {
|
||||
func (pls *Playlist) AddMediaFilesByID(mediaFileIds []string) {
|
||||
pos := len(pls.Tracks)
|
||||
for _, mfId := range mediaFileIds {
|
||||
pos++
|
||||
@@ -68,6 +84,7 @@ func (pls *Playlist) AddTracks(mediaFileIds []string) {
|
||||
}
|
||||
pls.Tracks = append(pls.Tracks, t)
|
||||
}
|
||||
pls.refreshStats()
|
||||
}
|
||||
|
||||
func (pls *Playlist) AddMediaFiles(mfs MediaFiles) {
|
||||
@@ -82,6 +99,7 @@ func (pls *Playlist) AddMediaFiles(mfs MediaFiles) {
|
||||
}
|
||||
pls.Tracks = append(pls.Tracks, t)
|
||||
}
|
||||
pls.refreshStats()
|
||||
}
|
||||
|
||||
func (pls Playlist) CoverArtID() ArtworkID {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
package model
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ func (t Tags) Add(name TagName, v string) {
|
||||
}
|
||||
|
||||
type TagRepository interface {
|
||||
Add(...Tag) error
|
||||
Add(libraryID int, tags ...Tag) error
|
||||
UpdateCounts() error
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
@@ -13,6 +15,9 @@ type User struct {
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
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
|
||||
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.
|
||||
@@ -22,6 +27,18 @@ type User struct {
|
||||
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 UserRepository interface {
|
||||
@@ -35,4 +52,8 @@ type UserRepository interface {
|
||||
FindByUsername(username string) (*User, error)
|
||||
// FindByUsernameWithPassword is the same as above, but also returns the decrypted password
|
||||
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
83
model/user_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -123,6 +123,7 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc {
|
||||
"missing": booleanFilter,
|
||||
"genre_id": tagIDFilter,
|
||||
"role_total_id": allRolesFilter,
|
||||
"library_id": libraryIdFilter,
|
||||
}
|
||||
// Add all album tags as filters
|
||||
for tag := range model.AlbumLevelTags() {
|
||||
@@ -184,9 +185,10 @@ func allRolesFilter(_ string, value interface{}) Sqlizer {
|
||||
}
|
||||
|
||||
func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
sql := r.newSelect()
|
||||
sql = r.withAnnotation(sql, "album.id")
|
||||
return r.count(sql, options...)
|
||||
query := r.newSelect()
|
||||
query = r.withAnnotation(query, "album.id")
|
||||
query = r.applyLibraryFilter(query)
|
||||
return r.count(query, options...)
|
||||
}
|
||||
|
||||
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 {
|
||||
sql := r.newSelect(options...).Columns("album.*")
|
||||
return r.withAnnotation(sql, "album.id")
|
||||
sql := r.newSelect(options...).Columns("album.*", "library.path as library_path", "library.name as library_name").
|
||||
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) {
|
||||
@@ -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.
|
||||
func (r *albumRepository) GetTouchedAlbums(libID int) (model.AlbumCursor, error) {
|
||||
query := r.selectAlbum().
|
||||
Join("library on library.id = album.library_id").
|
||||
Where(And{
|
||||
Eq{"library.id": libID},
|
||||
ConcatExpr("album.imported_at > library.last_scan_at"),
|
||||
@@ -346,15 +349,15 @@ func (r *albumRepository) purgeEmpty() error {
|
||||
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
|
||||
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 {
|
||||
return nil, fmt.Errorf("searching album by MBID %q: %w", q, err)
|
||||
}
|
||||
} 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 {
|
||||
return nil, fmt.Errorf("searching album by query %q: %w", q, err)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ type artistRepository struct {
|
||||
type dbArtist struct {
|
||||
*model.Artist `structs:",flatten"`
|
||||
SimilarArtists string `structs:"-" json:"-"`
|
||||
Stats string `structs:"-" json:"-"`
|
||||
LibraryStatsJSON string `structs:"-" json:"-"`
|
||||
}
|
||||
|
||||
type dbSimilarArtist struct {
|
||||
@@ -38,27 +38,45 @@ type dbSimilarArtist struct {
|
||||
}
|
||||
|
||||
func (a *dbArtist) PostScan() error {
|
||||
var stats map[string]map[string]int64
|
||||
if err := json.Unmarshal([]byte(a.Stats), &stats); err != nil {
|
||||
a.Artist.Stats = make(map[model.Role]model.ArtistStats)
|
||||
|
||||
if a.LibraryStatsJSON != "" {
|
||||
var rawLibStats map[string]map[string]map[string]int64
|
||||
if err := json.Unmarshal([]byte(a.LibraryStatsJSON), &rawLibStats); err != nil {
|
||||
return fmt.Errorf("parsing artist stats from db: %w", err)
|
||||
}
|
||||
a.Artist.Stats = make(map[model.Role]model.ArtistStats)
|
||||
for key, c := range stats {
|
||||
if key == "total" {
|
||||
a.Artist.Size = c["s"]
|
||||
a.Artist.SongCount = int(c["m"])
|
||||
a.Artist.AlbumCount = int(c["a"])
|
||||
|
||||
for _, stats := range rawLibStats {
|
||||
// Sum all libraries roles stats
|
||||
for key, stat := range stats {
|
||||
// Aggregate stats into the main Artist.Stats map
|
||||
artistStats := model.ArtistStats{
|
||||
SongCount: int(stat["m"]),
|
||||
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
|
||||
}
|
||||
a.Artist.Stats[role] = model.ArtistStats{
|
||||
SongCount: int(c["m"]),
|
||||
AlbumCount: int(c["a"]),
|
||||
Size: c["s"],
|
||||
|
||||
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
|
||||
if a.SimilarArtists == "" {
|
||||
return nil
|
||||
@@ -118,6 +136,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
||||
"starred": booleanFilter,
|
||||
"role": roleFilter,
|
||||
"missing": booleanFilter,
|
||||
"library_id": artistLibraryIdFilter,
|
||||
})
|
||||
r.setSortMappings(map[string]string{
|
||||
"name": "order_artist_name",
|
||||
@@ -127,9 +146,9 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
||||
"size": "stats->>'total'->>'s'",
|
||||
|
||||
// Stats by credits that are currently available
|
||||
"maincredit_song_count": "stats->>'maincredit'->>'m'",
|
||||
"maincredit_album_count": "stats->>'maincredit'->>'a'",
|
||||
"maincredit_size": "stats->>'maincredit'->>'a'",
|
||||
"maincredit_song_count": "sum(stats->>'maincredit'->>'m')",
|
||||
"maincredit_album_count": "sum(stats->>'maincredit'->>'a')",
|
||||
"maincredit_size": "sum(stats->>'maincredit'->>'s')",
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -137,26 +156,58 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
||||
func roleFilter(_ string, role any) Sqlizer {
|
||||
if role, ok := role.(string); 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}
|
||||
}
|
||||
|
||||
func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder {
|
||||
query := r.newSelect(options...).Columns("artist.*")
|
||||
query = r.withAnnotation(query, "artist.id")
|
||||
// artistLibraryIdFilter filters artists based on library access through the library_artist table
|
||||
func artistLibraryIdFilter(_ string, value interface{}) Sqlizer {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
query := r.newSelect()
|
||||
query = r.applyLibraryFilterToArtistQuery(query)
|
||||
query = r.withAnnotation(query, "artist.id")
|
||||
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) {
|
||||
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 {
|
||||
@@ -213,8 +264,15 @@ func (r *artistRepository) getIndexKey(a model.Artist) string {
|
||||
return "#"
|
||||
}
|
||||
|
||||
// TODO Cache the index (recalculate when there are changes to the DB)
|
||||
func (r *artistRepository) GetIndex(includeMissing bool, roles ...model.Role) (model.ArtistIndexes, error) {
|
||||
// GetIndex returns a list of artists grouped by the first letter of their name, or by the index group if configured.
|
||||
// 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"}
|
||||
if len(roles) > 0 {
|
||||
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}}
|
||||
}
|
||||
}
|
||||
|
||||
libFilter := artistLibraryIdFilter("library_id", libraryIds)
|
||||
if options.Filters == nil {
|
||||
options.Filters = libFilter
|
||||
} else {
|
||||
options.Filters = And{options.Filters, libFilter}
|
||||
}
|
||||
|
||||
artists, err := r.GetAll(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result model.ArtistIndexes
|
||||
for k, v := range slice.Group(artists, r.getIndexKey) {
|
||||
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.
|
||||
// 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) {
|
||||
var allTouchedArtistIDs []string
|
||||
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
|
||||
// This now calculates per-library statistics and stores them in library_artist.stats
|
||||
batchUpdateStatsSQL := `
|
||||
WITH artist_role_counters AS (
|
||||
SELECT jt.atom AS artist_id,
|
||||
mf.library_id,
|
||||
substr(
|
||||
replace(jt.path, '$.', ''),
|
||||
1,
|
||||
@@ -344,10 +414,11 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
|
||||
FROM media_file mf
|
||||
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
|
||||
GROUP BY jt.atom, role
|
||||
GROUP BY jt.atom, mf.library_id, role
|
||||
),
|
||||
artist_total_counters AS (
|
||||
SELECT mfa.artist_id,
|
||||
mf.library_id,
|
||||
'total' AS role,
|
||||
count(DISTINCT mf.album_id) AS album_count,
|
||||
count(DISTINCT mf.id) AS count,
|
||||
@@ -355,10 +426,11 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
|
||||
FROM media_file_artists mfa
|
||||
JOIN media_file mf ON mfa.media_file_id = mf.id
|
||||
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 (
|
||||
SELECT mfa.artist_id,
|
||||
mf.library_id,
|
||||
'maincredit' AS role,
|
||||
count(DISTINCT mf.album_id) AS album_count,
|
||||
count(DISTINCT mf.id) AS count,
|
||||
@@ -367,28 +439,30 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
|
||||
JOIN media_file mf ON mfa.media_file_id = mf.id
|
||||
WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
|
||||
AND mfa.role IN ('albumartist', 'artist')
|
||||
GROUP BY mfa.artist_id
|
||||
GROUP BY mfa.artist_id, mf.library_id
|
||||
),
|
||||
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
|
||||
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
|
||||
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 (
|
||||
SELECT artist_id AS id,
|
||||
library_artist_counters AS (
|
||||
SELECT artist_id,
|
||||
library_id,
|
||||
json_group_object(
|
||||
replace(role, '"', ''),
|
||||
json_object('a', album_count, 'm', count, 's', size)
|
||||
) AS counters
|
||||
FROM combined_counters
|
||||
GROUP BY artist_id
|
||||
GROUP BY artist_id, library_id
|
||||
)
|
||||
UPDATE artist
|
||||
SET stats = coalesce((SELECT counters FROM artist_counters ac WHERE ac.id = artist.id), '{}'),
|
||||
updated_at = datetime(current_timestamp, 'localtime')
|
||||
WHERE artist.id IN (ROLE_IDS_PLACEHOLDER) AND artist.id <> '';` // Will replace with actual placeholders
|
||||
UPDATE library_artist
|
||||
SET stats = coalesce((SELECT counters FROM library_artist_counters lac
|
||||
WHERE lac.artist_id = library_artist.artist_id
|
||||
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
|
||||
const batchSize = 1000
|
||||
@@ -433,15 +507,16 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
|
||||
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
|
||||
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 {
|
||||
return nil, fmt.Errorf("searching artist by MBID %q: %w", q, err)
|
||||
}
|
||||
} 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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
r.sortMappings["song_count"] = "stats->>'" + role + "'->>'m'"
|
||||
r.sortMappings["album_count"] = "stats->>'" + role + "'->>'a'"
|
||||
r.sortMappings["size"] = "stats->>'" + role + "'->>'s'"
|
||||
r.sortMappings["song_count"] = "sum(stats->>'" + role + "'->>'m')"
|
||||
r.sortMappings["album_count"] = "sum(stats->>'" + role + "'->>'a')"
|
||||
r.sortMappings["size"] = "sum(stats->>'" + role + "'->>'s')"
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -61,8 +61,9 @@ func newFolderRepository(ctx context.Context, db dbx.Builder) model.FolderReposi
|
||||
}
|
||||
|
||||
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")
|
||||
return r.applyLibraryFilter(sql)
|
||||
}
|
||||
|
||||
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) {
|
||||
sq := r.newSelect(opt...).Columns("count(*)")
|
||||
return r.count(sq)
|
||||
query := r.newSelect(opt...).Columns("count(*)")
|
||||
query = r.applyLibraryFilter(query)
|
||||
return r.count(query)
|
||||
}
|
||||
|
||||
func (r folderRepository) GetLastUpdates(lib model.Library) (map[string]model.FolderUpdateInfo, error) {
|
||||
|
||||
@@ -10,31 +10,18 @@ import (
|
||||
)
|
||||
|
||||
type genreRepository struct {
|
||||
sqlRepository
|
||||
*baseTagRepository
|
||||
}
|
||||
|
||||
func NewGenreRepository(ctx context.Context, db dbx.Builder) model.GenreRepository {
|
||||
r := &genreRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.registerModel(&model.Tag{}, map[string]filterFunc{
|
||||
"name": containsFilter("tag_value"),
|
||||
})
|
||||
r.setSortMappings(map[string]string{
|
||||
"name": "tag_name",
|
||||
})
|
||||
return r
|
||||
genreFilter := model.TagGenre
|
||||
return &genreRepository{
|
||||
baseTagRepository: newBaseTagRepository(ctx, db, &genreFilter),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *genreRepository) selectGenre(opt ...model.QueryOptions) SelectBuilder {
|
||||
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})
|
||||
return r.newSelect(opt...)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (r *genreRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.count(r.selectGenre(), r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
// Override ResourceRepository methods to return Genre objects instead of Tag objects
|
||||
|
||||
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
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
@@ -59,10 +44,6 @@ func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (interface{}, er
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *genreRepository) EntityName() string {
|
||||
return r.tableName
|
||||
}
|
||||
|
||||
func (r *genreRepository) NewInstance() interface{} {
|
||||
return &model.Genre{}
|
||||
}
|
||||
|
||||
256
persistence/genre_repository_test.go
Normal file
256
persistence/genre_repository_test.go
Normal 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{}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,10 +2,13 @@ package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"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 {
|
||||
if l.ID == model.DefaultLibraryID {
|
||||
currentLib, err := r.Get(1)
|
||||
// if we are creating it, it's ok.
|
||||
if err == nil { // it exists, so we are updating it
|
||||
if currentLib.Path != l.Path {
|
||||
return fmt.Errorf("%w: path for library with ID 1 cannot be changed", model.ErrValidation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
l.UpdatedAt = time.Now()
|
||||
if l.ID == 0 {
|
||||
// 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,
|
||||
"updated_at": time.Now(),
|
||||
"default_new_users": l.DefaultNewUsers,
|
||||
"updated_at": l.UpdatedAt,
|
||||
}
|
||||
if l.ID != 0 {
|
||||
cols["id"] = l.ID
|
||||
sq := Update(r.tableName).SetMap(cols).Where(Eq{"id": l.ID})
|
||||
rowsAffected, updateErr := r.executeSQL(sq)
|
||||
if updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
|
||||
sq := Insert(r.tableName).SetMap(cols).
|
||||
Suffix(`on conflict(id) do update set name = excluded.name, path = excluded.path,
|
||||
remote_path = excluded.remote_path, updated_at = excluded.updated_at`)
|
||||
_, err := r.executeSQL(sq)
|
||||
if err != nil {
|
||||
libLock.Lock()
|
||||
defer libLock.Unlock()
|
||||
libCache[l.ID] = l.Path
|
||||
// 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 {
|
||||
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
|
||||
// This is a temporary method to store the music folder path from the config in the DB
|
||||
func (r *libraryRepository) StoreMusicFolder() error {
|
||||
sq := Update(r.tableName).Set("path", conf.Server.MusicFolder).
|
||||
Set("updated_at", time.Now()).
|
||||
Where(Eq{"id": hardCodedMusicFolderID})
|
||||
Where(Eq{"id": model.DefaultLibraryID})
|
||||
_, err := r.executeSQL(sq)
|
||||
if err != nil {
|
||||
libLock.Lock()
|
||||
defer libLock.Unlock()
|
||||
libCache[hardCodedMusicFolderID] = conf.Server.MusicFolder
|
||||
libCache[model.DefaultLibraryID] = conf.Server.MusicFolder
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -150,6 +190,7 @@ func (r *libraryRepository) ScanInProgress() (bool, error) {
|
||||
func (r *libraryRepository) RefreshStats(id int) error {
|
||||
var songsRes, albumsRes, artistsRes, foldersRes, filesRes, missingRes struct{ Count int64 }
|
||||
var sizeRes struct{ Sum int64 }
|
||||
var durationRes struct{ Sum float64 }
|
||||
|
||||
err := run.Parallel(
|
||||
func() error {
|
||||
@@ -180,6 +221,9 @@ func (r *libraryRepository) RefreshStats(id int) error {
|
||||
func() error {
|
||||
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 {
|
||||
return err
|
||||
@@ -193,12 +237,34 @@ func (r *libraryRepository) RefreshStats(id int) error {
|
||||
Set("total_files", filesRes.Count).
|
||||
Set("total_missing_files", missingRes.Count).
|
||||
Set("total_size", sizeRes.Sum).
|
||||
Set("total_duration", durationRes.Sum).
|
||||
Set("updated_at", time.Now()).
|
||||
Where(Eq{"id": id})
|
||||
_, err = r.executeSQL(sq)
|
||||
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) {
|
||||
sq := r.newSelect(ops...).Columns("*")
|
||||
res := model.Libraries{}
|
||||
@@ -206,4 +272,72 @@ func (r *libraryRepository) GetAll(ops ...model.QueryOptions) (model.Libraries,
|
||||
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 _ rest.Repository = (*libraryRepository)(nil)
|
||||
|
||||
@@ -22,6 +22,96 @@ var _ = Describe("LibraryRepository", func() {
|
||||
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() {
|
||||
libBefore, err := repo.Get(1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@@ -32,6 +122,7 @@ var _ = Describe("LibraryRepository", func() {
|
||||
|
||||
var songsRes, albumsRes, artistsRes, foldersRes, filesRes, missingRes struct{ Count 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 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 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(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.TotalAlbums).To(Equal(int(albumsRes.Count)))
|
||||
@@ -48,5 +140,6 @@ var _ = Describe("LibraryRepository", func() {
|
||||
Expect(libAfter.TotalFiles).To(Equal(int(filesRes.Count)))
|
||||
Expect(libAfter.TotalMissingFiles).To(Equal(int(missingRes.Count)))
|
||||
Expect(libAfter.TotalSize).To(Equal(sizeRes.Sum))
|
||||
Expect(libAfter.TotalDuration).To(Equal(durationRes.Sum))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -96,6 +96,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc {
|
||||
"genre_id": tagIDFilter,
|
||||
"missing": booleanFilter,
|
||||
"artists_id": artistFilter,
|
||||
"library_id": libraryIdFilter,
|
||||
}
|
||||
// Add all album tags as filters
|
||||
for tag := range model.TagMappings() {
|
||||
@@ -116,6 +117,7 @@ func mediaFileRecentlyAddedSort() string {
|
||||
func (r *mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
query := r.newSelect()
|
||||
query = r.withAnnotation(query, "media_file.id")
|
||||
query = r.applyLibraryFilter(query)
|
||||
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 {
|
||||
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")
|
||||
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) {
|
||||
@@ -273,7 +276,7 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC
|
||||
if err != nil {
|
||||
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").
|
||||
Where("pid in ("+subQText+")", subQArgs...).
|
||||
Where(Or{
|
||||
@@ -294,15 +297,57 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC
|
||||
}, 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
|
||||
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 {
|
||||
return nil, fmt.Errorf("searching media_file by MBID %q: %w", q, err)
|
||||
}
|
||||
} 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 {
|
||||
return nil, fmt.Errorf("searching media_file by query %q: %w", q, err)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func mf(mf model.MediaFile) model.MediaFile {
|
||||
mf.Tags = model.Tags{}
|
||||
mf.LibraryID = 1
|
||||
mf.LibraryPath = "music" // Default folder
|
||||
mf.LibraryName = "Music Library"
|
||||
mf.Participants = model.Participants{
|
||||
model.RoleArtist: model.ParticipantList{
|
||||
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 {
|
||||
al.LibraryID = 1
|
||||
al.LibraryPath = "music"
|
||||
al.LibraryName = "Music Library"
|
||||
al.Discs = model.Discs{}
|
||||
al.Tags = model.Tags{}
|
||||
al.Participants = model.Participants{}
|
||||
@@ -138,14 +141,13 @@ var _ = BeforeSuite(func() {
|
||||
}
|
||||
}
|
||||
|
||||
//gr := NewGenreRepository(ctx, conn)
|
||||
//for i := range testGenres {
|
||||
// g := testGenres[i]
|
||||
// err := gr.Put(&g)
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
//}
|
||||
// Associate users with library 1 (default test library)
|
||||
for i := range testUsers {
|
||||
err := ur.SetUserLibraries(testUsers[i].ID, []int{1})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
alr := NewAlbumRepository(ctx, conn).(*albumRepository)
|
||||
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)
|
||||
for i := range testSongs {
|
||||
err := mr.Put(&testSongs[i])
|
||||
@@ -190,9 +201,9 @@ var _ = BeforeSuite(func() {
|
||||
Public: true,
|
||||
SongCount: 2,
|
||||
}
|
||||
plsBest.AddTracks([]string{"1001", "1003"})
|
||||
plsBest.AddMediaFilesByID([]string{"1001", "1003"})
|
||||
plsCool = model.Playlist{Name: "Cool", OwnerID: "userid", OwnerName: "userid"}
|
||||
plsCool.AddTracks([]string{"1004"})
|
||||
plsCool.AddMediaFilesByID([]string{"1004"})
|
||||
testPlaylists = []*model.Playlist{&plsBest, &plsCool}
|
||||
|
||||
pr := NewPlaylistRepository(ctx, conn)
|
||||
|
||||
@@ -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)
|
||||
return nil, err
|
||||
}
|
||||
pls.Tracks = tracks
|
||||
pls.SetTracks(tracks)
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
||||
From("media_file").LeftJoin("annotation on (" +
|
||||
"annotation.item_id = media_file.id" +
|
||||
" AND annotation.item_type = 'media_file'" +
|
||||
" AND annotation.user_id = '" + userId(r.ctx) + "')")
|
||||
" AND annotation.user_id = '" + usr.ID + "')")
|
||||
sq = r.addCriteria(sq, rules)
|
||||
insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
|
||||
_, 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) {
|
||||
sel = r.applyLibraryFilter(sel, "f")
|
||||
userID := loggedUser(r.ctx).ID
|
||||
tracksQuery := sel.
|
||||
Columns(
|
||||
"coalesce(starred, 0) as starred",
|
||||
@@ -389,11 +391,12 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla
|
||||
"f.*",
|
||||
"playlist_tracks.*",
|
||||
"library.path as library_path",
|
||||
"library.name as library_name",
|
||||
).
|
||||
LeftJoin("annotation on (" +
|
||||
"annotation.item_id = media_file_id" +
|
||||
" 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("library on f.library_id = library.id").
|
||||
Where(Eq{"playlist_id": id})
|
||||
|
||||
@@ -79,13 +79,13 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
It("Put/Exists/Delete", func() {
|
||||
By("saves the playlist to the DB")
|
||||
newPls := model.Playlist{Name: "Great!", OwnerID: "userid"}
|
||||
newPls.AddTracks([]string{"1004", "1003"})
|
||||
newPls.AddMediaFilesByID([]string{"1004", "1003"})
|
||||
|
||||
By("saves the playlist to the DB")
|
||||
Expect(repo.Put(&newPls)).To(BeNil())
|
||||
|
||||
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())
|
||||
saved, _ := repo.GetWithTracks(newPls.ID, true, false)
|
||||
Expect(saved.Tracks).To(HaveLen(3))
|
||||
|
||||
@@ -48,6 +48,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
|
||||
p.tableName = "playlist_tracks"
|
||||
p.registerModel(&model.PlaylistTrack{}, map[string]filterFunc{
|
||||
"missing": booleanFilter,
|
||||
"library_id": libraryIdFilter,
|
||||
})
|
||||
p.setSortMappings(
|
||||
map[string]string{
|
||||
@@ -84,11 +85,12 @@ func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, er
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
|
||||
userID := loggedUser(r.ctx).ID
|
||||
sel := r.newSelect().
|
||||
LeftJoin("annotation on ("+
|
||||
"annotation.item_id = media_file_id"+
|
||||
" AND annotation.item_type = 'media_file'"+
|
||||
" AND annotation.user_id = '"+userId(r.ctx)+"')").
|
||||
" AND annotation.user_id = '"+userID+"')").
|
||||
Columns(
|
||||
"coalesce(starred, 0) as starred",
|
||||
"coalesce(play_count, 0) as play_count",
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
@@ -82,7 +83,20 @@ func (r *scrobbleBufferRepository) Next(service string, userId string) (*model.S
|
||||
if err != nil {
|
||||
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)
|
||||
r.ctx = originalCtx // Restore original context
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -15,15 +15,14 @@ import (
|
||||
const annotationTable = "annotation"
|
||||
|
||||
func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) SelectBuilder {
|
||||
if userId(r.ctx) == invalidUserId {
|
||||
userID := loggedUser(r.ctx).ID
|
||||
if userID == invalidUserId {
|
||||
return query
|
||||
}
|
||||
query = query.
|
||||
LeftJoin("annotation on ("+
|
||||
"annotation.item_id = "+idField+
|
||||
// item_ids are unique across different item_types, so the clause below is not needed
|
||||
//" AND annotation.item_type = '"+r.tableName+"'"+
|
||||
" AND annotation.user_id = '"+userId(r.ctx)+"')").
|
||||
" AND annotation.user_id = '"+userID+"')").
|
||||
Columns(
|
||||
"coalesce(starred, 0) as starred",
|
||||
"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 {
|
||||
userID := loggedUser(r.ctx).ID
|
||||
return And{
|
||||
Eq{annotationTable + ".user_id": userId(r.ctx)},
|
||||
Eq{annotationTable + ".user_id": userID},
|
||||
Eq{annotationTable + ".item_type": r.tableName},
|
||||
Eq{annotationTable + ".item_id": itemID},
|
||||
}
|
||||
@@ -56,8 +56,9 @@ func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...strin
|
||||
}
|
||||
c, err := r.executeSQL(upd)
|
||||
if c == 0 || errors.Is(err, sql.ErrNoRows) {
|
||||
userID := loggedUser(r.ctx).ID
|
||||
for _, itemID := range itemIDs {
|
||||
values["user_id"] = userId(r.ctx)
|
||||
values["user_id"] = userID
|
||||
values["item_type"] = r.tableName
|
||||
values["item_id"] = itemID
|
||||
ins := Insert(annotationTable).SetMap(values)
|
||||
@@ -86,8 +87,9 @@ func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
|
||||
c, err := r.executeSQL(upd)
|
||||
|
||||
if c == 0 || errors.Is(err, sql.ErrNoRows) {
|
||||
userID := loggedUser(r.ctx).ID
|
||||
values := map[string]interface{}{}
|
||||
values["user_id"] = userId(r.ctx)
|
||||
values["user_id"] = userID
|
||||
values["item_type"] = r.tableName
|
||||
values["item_id"] = itemID
|
||||
values["play_count"] = 1
|
||||
|
||||
@@ -49,27 +49,14 @@ type sqlRepository struct {
|
||||
|
||||
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 {
|
||||
if user, ok := request.UserFrom(ctx); !ok {
|
||||
return &model.User{}
|
||||
return &model.User{ID: invalidUserId}
|
||||
} else {
|
||||
return &user
|
||||
}
|
||||
}
|
||||
|
||||
func isAdmin(ctx context.Context) bool {
|
||||
user := loggedUser(ctx)
|
||||
return user.IsAdmin
|
||||
}
|
||||
|
||||
func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) {
|
||||
if r.tableName == "" {
|
||||
r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.")
|
||||
@@ -199,10 +186,52 @@ func (r sqlRepository) applyFilters(sq SelectBuilder, options ...model.QueryOpti
|
||||
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 {
|
||||
// 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
|
||||
userIDHash := md5.Sum([]byte(userId(r.ctx)))
|
||||
userIDHash := md5.Sum([]byte(loggedUser(r.ctx).ID))
|
||||
return fmt.Sprintf("%s|%x", r.tableName, userIDHash)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,21 +15,20 @@ import (
|
||||
const bookmarkTable = "bookmark"
|
||||
|
||||
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.
|
||||
LeftJoin("bookmark on (" +
|
||||
"bookmark.item_id = " + idField +
|
||||
// item_ids are unique across different item_types, so the clause below is not needed
|
||||
//" AND bookmark.item_type = '" + r.tableName + "'" +
|
||||
" AND bookmark.user_id = '" + userId(r.ctx) + "')").
|
||||
" AND bookmark.user_id = '" + userID + "')").
|
||||
Columns("coalesce(position, 0) as bookmark_position")
|
||||
}
|
||||
|
||||
func (r sqlRepository) bmkID(itemID ...string) 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_id": itemID},
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
// 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)
|
||||
|
||||
228
persistence/tag_library_filtering_test.go
Normal file
228
persistence/tag_library_filtering_test.go
Normal 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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -7,26 +7,22 @@ import (
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type tagRepository struct {
|
||||
sqlRepository
|
||||
*baseTagRepository
|
||||
}
|
||||
|
||||
func NewTagRepository(ctx context.Context, db dbx.Builder) model.TagRepository {
|
||||
r := &tagRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "tag"
|
||||
r.registerModel(&model.Tag{}, nil)
|
||||
return r
|
||||
return &tagRepository{
|
||||
baseTagRepository: newBaseTagRepository(ctx, db, nil), // nil = no filter, works with all tags
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
sq := Insert(r.tableName).Columns("id", "tag_name", "tag_value").
|
||||
Suffix("on conflict (id) do nothing")
|
||||
@@ -37,34 +33,41 @@ func (r *tagRepository) Add(tags ...model.Tag) error {
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (r *tagRepository) UpdateCounts() error {
|
||||
template := `
|
||||
with updated_values as (
|
||||
select jt.value as id, count(distinct %[1]s.id) as %[1]s_count
|
||||
from %[1]s
|
||||
join json_tree(tags, '$.genre') as jt
|
||||
where atom is not null
|
||||
and key = 'id'
|
||||
group by jt.value
|
||||
)
|
||||
update tag
|
||||
set %[1]s_count = updated_values.%[1]s_count
|
||||
from updated_values
|
||||
where tag.id = updated_values.id;
|
||||
INSERT INTO library_tag (tag_id, library_id, %[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
|
||||
JOIN json_tree(%[1]s.tags, '$.genre') as jt ON jt.atom IS NOT NULL AND jt.key = 'id'
|
||||
GROUP BY jt.value, %[1]s.library_id
|
||||
ON CONFLICT (tag_id, library_id)
|
||||
DO UPDATE SET %[1]s_count = excluded.%[1]s_count;
|
||||
`
|
||||
|
||||
for _, table := range []string{"album", "media_file"} {
|
||||
start := time.Now()
|
||||
query := Expr(fmt.Sprintf(template, table))
|
||||
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 {
|
||||
return fmt.Errorf("updating %s tag counts: %w", table, err)
|
||||
return fmt.Errorf("updating %s library tag counts: %w", table, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -74,6 +77,11 @@ func (r *tagRepository) purgeUnused() error {
|
||||
del := Delete(r.tableName).Where(`
|
||||
id not in (select jt.value
|
||||
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
|
||||
and key = 'id')
|
||||
`)
|
||||
@@ -87,30 +95,4 @@ func (r *tagRepository) purgeUnused() error {
|
||||
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{}
|
||||
|
||||
249
persistence/tag_repository_test.go
Normal file
249
persistence/tag_repository_test.go
Normal 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{}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -41,7 +41,7 @@ func (r *transcodingRepository) FindByFormat(format string) (*model.Transcoding,
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Put(t *model.Transcoding) error {
|
||||
if !isAdmin(r.ctx) {
|
||||
if !loggedUser(r.ctx).IsAdmin {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
_, err := r.put(t.ID, t)
|
||||
@@ -72,7 +72,7 @@ func (r *transcodingRepository) NewInstance() interface{} {
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Save(entity interface{}) (string, error) {
|
||||
if !isAdmin(r.ctx) {
|
||||
if !loggedUser(r.ctx).IsAdmin {
|
||||
return "", rest.ErrPermissionDenied
|
||||
}
|
||||
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 {
|
||||
if !isAdmin(r.ctx) {
|
||||
if !loggedUser(r.ctx).IsAdmin {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
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 {
|
||||
if !isAdmin(r.ctx) {
|
||||
if !loggedUser(r.ctx).IsAdmin {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
err := r.delete(Eq{"id": id})
|
||||
|
||||
@@ -3,6 +3,7 @@ package persistence
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
@@ -24,6 +26,26 @@ type userRepository struct {
|
||||
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 (
|
||||
once sync.Once
|
||||
encKey []byte
|
||||
@@ -33,8 +55,10 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository
|
||||
r := &userRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "user"
|
||||
r.registerModel(&model.User{}, map[string]filterFunc{
|
||||
"password": invalidFilter(ctx),
|
||||
"name": r.withTableName(startsWithFilter),
|
||||
})
|
||||
once.Do(func() {
|
||||
_ = r.initPasswordEncryptionKey()
|
||||
@@ -42,28 +66,48 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository
|
||||
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) {
|
||||
return r.count(Select(), qo...)
|
||||
}
|
||||
|
||||
func (r *userRepository) Get(id string) (*model.User, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Eq{"id": id})
|
||||
var res model.User
|
||||
sel := r.selectUserWithLibraries().Where(Eq{"user.id": id})
|
||||
var res dbUser
|
||||
err := r.queryOne(sel, &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &res, nil
|
||||
return res.User, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) GetAll(options ...model.QueryOptions) (model.Users, error) {
|
||||
sel := r.newSelect(options...).Columns("*")
|
||||
res := model.Users{}
|
||||
sel := r.selectUserWithLibraries(options...)
|
||||
var res dbUsers
|
||||
err := r.queryAll(sel, &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
return res.toModels(), nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
delete(values, "current_password")
|
||||
|
||||
// Save/update the user
|
||||
update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values)
|
||||
count, err := r.executeSQL(update)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-assign all libraries to admin users in a single SQL operation
|
||||
if u.IsAdmin {
|
||||
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) {
|
||||
sel := r.newSelect(model.QueryOptions{Sort: "updated_at", Max: 1}).Columns("*").Where(Eq{"is_admin": true})
|
||||
var usr model.User
|
||||
sel := r.selectUserWithLibraries(model.QueryOptions{Sort: "updated_at", Max: 1}).Where(Eq{"user.is_admin": true})
|
||||
var usr dbUser
|
||||
err := r.queryOne(sel, &usr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &usr, nil
|
||||
return usr.User, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) FindByUsername(username string) (*model.User, error) {
|
||||
sel := r.newSelect().Columns("*").Where(Expr("user_name = ? COLLATE NOCASE", username))
|
||||
var usr model.User
|
||||
sel := r.selectUserWithLibraries().Where(Expr("user.user_name = ? COLLATE NOCASE", username))
|
||||
var usr dbUser
|
||||
err := r.queryOne(sel, &usr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &usr, nil
|
||||
return usr.User, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) FindByUsernameWithPassword(username string) (*model.User, error) {
|
||||
@@ -365,6 +436,39 @@ func (r *userRepository) decryptAllPasswords(users model.Users) error {
|
||||
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 _ rest.Repository = (*userRepository)(nil)
|
||||
var _ rest.Persistable = (*userRepository)(nil)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -235,4 +236,330 @@ var _ = Describe("UserRepository", func() {
|
||||
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(®ularUser)
|
||||
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(®ularUser)
|
||||
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(®ularUser)
|
||||
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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -116,6 +116,24 @@ type controller struct {
|
||||
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
|
||||
func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed time.Duration, lastErr string) {
|
||||
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() {
|
||||
elapsed = time.Since(startTime)
|
||||
} else {
|
||||
// If scan is not running, try to get the last scan time for the library
|
||||
lib, err := s.ds.Library(ctx).Get(1) //TODO Multi-library
|
||||
if err == nil {
|
||||
elapsed = lib.LastScanAt.Sub(startTime)
|
||||
// If scan is not running, calculate elapsed time using the most recent scan time
|
||||
lastScanTime, err := s.getLastScanTime(ctx)
|
||||
if err == nil && !lastScanTime.IsZero() {
|
||||
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) {
|
||||
lib, err := s.ds.Library(ctx).Get(1) //TODO Multi-library
|
||||
lastScanTime, err := s.getLastScanTime(ctx)
|
||||
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)
|
||||
@@ -151,7 +169,7 @@ func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
|
||||
if running.Load() {
|
||||
status := &StatusInfo{
|
||||
Scanning: true,
|
||||
LastScan: lib.LastScanAt,
|
||||
LastScan: lastScanTime,
|
||||
Count: s.count.Load(),
|
||||
FolderCount: s.folderCount.Load(),
|
||||
LastError: lastErr,
|
||||
@@ -167,7 +185,7 @@ func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
|
||||
}
|
||||
return &StatusInfo{
|
||||
Scanning: false,
|
||||
LastScan: lib.LastScanAt,
|
||||
LastScan: lastScanTime,
|
||||
Count: uint32(count),
|
||||
FolderCount: uint32(folderCount),
|
||||
LastError: lastErr,
|
||||
@@ -198,7 +216,6 @@ func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]strin
|
||||
|
||||
// Prepare the context for the scan
|
||||
ctx := request.AddValues(s.rootCtx, requestCtx)
|
||||
ctx = events.BroadcastToAll(ctx)
|
||||
ctx = auth.WithAdminUser(ctx, s.ds)
|
||||
|
||||
// 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 s.changesDetected {
|
||||
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
|
||||
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) {
|
||||
s.broker.SendMessage(ctx, status)
|
||||
s.broker.SendBroadcastMessage(ctx, status)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
@@ -32,7 +31,6 @@ var _ = Describe("Controller", func() {
|
||||
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||
ds.MockedProperty = &tests.MockedPropertyRepo{}
|
||||
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() {
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
|
||||
func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore, cw artwork.CacheWarmer, libs []model.Library) *phaseFolders {
|
||||
var jobs []*scanJob
|
||||
var updatedLibs []model.Library
|
||||
for _, lib := range libs {
|
||||
if lib.LastScanStartedAt.IsZero() {
|
||||
err := ds.Library(ctx).ScanBegin(lib.ID, state.fullScan)
|
||||
@@ -54,7 +55,12 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor
|
||||
continue
|
||||
}
|
||||
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}
|
||||
}
|
||||
|
||||
@@ -336,7 +342,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
|
||||
}
|
||||
|
||||
// Save all tags to DB
|
||||
err = tagRepo.Add(entry.tags...)
|
||||
err = tagRepo.Add(entry.job.lib.ID, entry.tags...)
|
||||
if err != nil {
|
||||
log.Error(p.ctx, "Scanner: Error persisting tags to DB", "folder", entry.path, err)
|
||||
return err
|
||||
@@ -418,12 +424,14 @@ func (p *phaseFolders) persistAlbum(repo model.AlbumRepository, a *model.Album,
|
||||
if prevID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reassign annotation from previous album to new album
|
||||
log.Trace(p.ctx, "Reassigning album annotations", "from", prevID, "to", a.ID, "album", a.Name)
|
||||
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)
|
||||
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
|
||||
if err := repo.CopyAttributes(prevID, a.ID, "created_at"); err != nil {
|
||||
// Silently ignore when the previous album is not found
|
||||
|
||||
@@ -3,6 +3,7 @@ package scanner
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
ppl "github.com/google/go-pipeline/pkg/pipeline"
|
||||
@@ -35,10 +36,17 @@ type phaseMissingTracks struct {
|
||||
ds model.DataStore
|
||||
totalMatched atomic.Uint32
|
||||
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 {
|
||||
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 {
|
||||
@@ -52,17 +60,15 @@ func (p *phaseMissingTracks) producer() ppl.Producer[*missingTracks] {
|
||||
func (p *phaseMissingTracks) produce(put func(tracks *missingTracks)) error {
|
||||
count := 0
|
||||
var putIfMatched = func(mt missingTracks) {
|
||||
if mt.pid != "" && len(mt.matched) > 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)
|
||||
if mt.pid != "" && len(mt.missing) > 0 {
|
||||
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++
|
||||
put(&mt)
|
||||
}
|
||||
}
|
||||
libs, err := p.ds.Library(p.ctx).GetAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading libraries: %w", err)
|
||||
}
|
||||
for _, lib := range libs {
|
||||
for _, lib := range p.state.libraries {
|
||||
if lib.LastScanStartedAt.IsZero() {
|
||||
continue
|
||||
}
|
||||
@@ -104,10 +110,13 @@ func (p *phaseMissingTracks) produce(put func(tracks *missingTracks)) error {
|
||||
func (p *phaseMissingTracks) stages() []ppl.Stage[*missingTracks] {
|
||||
return []ppl.Stage[*missingTracks]{
|
||||
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) {
|
||||
hasMatches := false
|
||||
|
||||
for _, ms := range in.missing {
|
||||
var exactMatch model.MediaFile
|
||||
var equivalentMatch model.MediaFile
|
||||
@@ -132,6 +141,7 @@ func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTr
|
||||
return nil, err
|
||||
}
|
||||
p.totalMatched.Add(1)
|
||||
hasMatches = true
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -145,6 +155,7 @@ func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTr
|
||||
return nil, err
|
||||
}
|
||||
p.totalMatched.Add(1)
|
||||
hasMatches = true
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -157,23 +168,141 @@ func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTr
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
discardedID := mt.ID
|
||||
mt.ID = ms.ID
|
||||
err := tx.MediaFile(p.ctx).Put(&mt)
|
||||
discardedID := target.ID
|
||||
oldAlbumID := missing.AlbumID
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -28,7 +28,9 @@ var _ = Describe("phaseMissingTracks", func() {
|
||||
lr = &tests.MockLibraryRepo{}
|
||||
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}
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -68,12 +70,31 @@ var _ = Describe("phaseMissingTracks", func() {
|
||||
|
||||
err := phase.produce(put)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(produced).To(HaveLen(1))
|
||||
Expect(produced[0].pid).To(Equal("A"))
|
||||
Expect(produced[0].missing).To(HaveLen(1))
|
||||
Expect(produced[0].matched).To(HaveLen(1))
|
||||
Expect(produced).To(HaveLen(2))
|
||||
// PID A should have both missing and matched tracks
|
||||
var pidA *missingTracks
|
||||
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{
|
||||
{ID: "1", PID: "A", Missing: true, LibraryID: 1},
|
||||
{ID: "2", PID: "B", Missing: true, LibraryID: 1},
|
||||
@@ -82,7 +103,22 @@ var _ = Describe("phaseMissingTracks", func() {
|
||||
|
||||
err := phase.produce(put)
|
||||
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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,6 +28,7 @@ type scanState struct {
|
||||
progress chan<- *ProgressInfo
|
||||
fullScan bool
|
||||
changesDetected atomic.Bool
|
||||
libraries model.Libraries // Store libraries list for consistency across phases
|
||||
}
|
||||
|
||||
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))
|
||||
return
|
||||
}
|
||||
state.libraries = 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),
|
||||
|
||||
// Update last_scan_completed_at for all libraries
|
||||
s.runUpdateLibraries(ctx, libs, &state),
|
||||
s.runUpdateLibraries(ctx, &state),
|
||||
|
||||
// Optimize DB
|
||||
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 {
|
||||
start := time.Now()
|
||||
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)
|
||||
if err != nil {
|
||||
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: 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
|
||||
}, "scanner: update libraries")
|
||||
}
|
||||
|
||||
831
scanner/scanner_multilibrary_test.go
Normal file
831
scanner/scanner_multilibrary_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -58,12 +58,14 @@ var _ = Describe("Scanner", Ordered, func() {
|
||||
})
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.MusicFolder = "fake:///music" // Set to match test library path
|
||||
conf.Server.DevExternalScanner = false
|
||||
|
||||
db.Init(ctx)
|
||||
DeferCleanup(func() {
|
||||
Expect(tests.ClearDB()).To(Succeed())
|
||||
})
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DevExternalScanner = false
|
||||
|
||||
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||
mfRepo = &mockMediaFileRepo{
|
||||
@@ -71,6 +73,16 @@ var _ = Describe("Scanner", Ordered, func() {
|
||||
}
|
||||
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(),
|
||||
core.NewPlaylists(ds), metrics.NewNoopInstance())
|
||||
|
||||
|
||||
@@ -5,42 +5,67 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/storage"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
)
|
||||
|
||||
type Watcher interface {
|
||||
Run(ctx context.Context) error
|
||||
Watch(ctx context.Context, lib *model.Library) error
|
||||
StopWatching(ctx context.Context, libraryID int) error
|
||||
}
|
||||
|
||||
type watcher struct {
|
||||
mainCtx context.Context
|
||||
ds model.DataStore
|
||||
scanner Scanner
|
||||
triggerWait time.Duration
|
||||
watcherNotify chan model.Library
|
||||
libraryWatchers map[int]*libraryWatcherInstance
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewWatcher(ds model.DataStore, s Scanner) Watcher {
|
||||
return &watcher{ds: ds, scanner: s, triggerWait: conf.Server.Scanner.WatcherWait}
|
||||
type libraryWatcherInstance struct {
|
||||
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 {
|
||||
// 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()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting libraries: %w", err)
|
||||
}
|
||||
|
||||
watcherChan := make(chan struct{})
|
||||
defer close(watcherChan)
|
||||
|
||||
// Start a watcher for each library
|
||||
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.Stop()
|
||||
waiting := false
|
||||
@@ -68,61 +93,137 @@ func (w *watcher) Run(ctx context.Context) error {
|
||||
}
|
||||
}()
|
||||
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
|
||||
case <-watcherChan:
|
||||
case lib := <-w.watcherNotify:
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Watcher: Error creating storage", "library", lib.ID, "path", lib.Path, err)
|
||||
return
|
||||
return fmt.Errorf("creating storage: %w", err)
|
||||
}
|
||||
|
||||
fsys, err := s.FS()
|
||||
if err != nil {
|
||||
log.Error(ctx, "Watcher: Error getting FS", "library", lib.ID, "path", lib.Path, err)
|
||||
return
|
||||
return fmt.Errorf("getting FS: %w", err)
|
||||
}
|
||||
|
||||
watcher, ok := s.(storage.Watcher)
|
||||
if !ok {
|
||||
log.Info(ctx, "Watcher not supported", "library", lib.ID, "path", lib.Path)
|
||||
return
|
||||
log.Info(ctx, "Watcher not supported for storage type", "libraryID", lib.ID, "path", lib.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
c, err := watcher.Start(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Watcher: Error watching library", "library", lib.ID, "path", lib.Path, err)
|
||||
return
|
||||
return fmt.Errorf("starting watcher: %w", err)
|
||||
}
|
||||
|
||||
absLibPath, err := filepath.Abs(lib.Path)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Watcher: Error converting lib.Path to absolute", "library", lib.ID, "path", lib.Path, err)
|
||||
return
|
||||
return fmt.Errorf("converting to absolute path: %w", err)
|
||||
}
|
||||
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 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
log.Debug(ctx, "Watcher stopped due to context cancellation", "libraryID", lib.ID, "name", lib.Name)
|
||||
return nil
|
||||
case path := <-c:
|
||||
path, err = filepath.Rel(absLibPath, path)
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ type eventCtxKey string
|
||||
|
||||
const broadcastToAllKey eventCtxKey = "broadcastToAll"
|
||||
|
||||
// BroadcastToAll is a context key that can be used to broadcast an event to all clients
|
||||
func BroadcastToAll(ctx context.Context) context.Context {
|
||||
// broadcastToAll is a context key that can be used to broadcast an event to all clients
|
||||
func broadcastToAll(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, broadcastToAllKey, true)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
type Broker interface {
|
||||
http.Handler
|
||||
SendMessage(ctx context.Context, event Event)
|
||||
SendBroadcastMessage(ctx context.Context, event Event)
|
||||
}
|
||||
|
||||
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) {
|
||||
msg := b.prepareMessage(ctx, evt)
|
||||
log.Trace("Broker received new event", "type", msg.event, "data", msg.data)
|
||||
@@ -280,4 +286,6 @@ type noopBroker struct {
|
||||
http.Handler
|
||||
}
|
||||
|
||||
func (b noopBroker) SendBroadcastMessage(context.Context, Event) {}
|
||||
|
||||
func (noopBroker) SendMessage(context.Context, Event) {}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
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
|
||||
configBytes, err := json.Marshal(*conf.Server)
|
||||
|
||||
@@ -1,42 +1,73 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"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/auth"
|
||||
"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/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() {
|
||||
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(®ularUser)).To(Succeed())
|
||||
})
|
||||
|
||||
Context("when user is not admin", func() {
|
||||
It("returns unauthorized", func() {
|
||||
req := httptest.NewRequest("GET", "/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
ctx := request.WithUser(req.Context(), model.User{IsAdmin: false})
|
||||
Describe("GET /api/config", func() {
|
||||
Context("as admin user", func() {
|
||||
var adminToken string
|
||||
|
||||
getConfig(w, req.WithContext(ctx))
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
adminToken, err = auth.CreateToken(&adminUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
Context("when user is admin", func() {
|
||||
It("returns config successfully", func() {
|
||||
req := httptest.NewRequest("GET", "/config", nil)
|
||||
req := createAuthenticatedConfigRequest(adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
|
||||
|
||||
getConfig(w, req.WithContext(ctx))
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var resp configResponse
|
||||
@@ -53,10 +84,10 @@ var _ = Describe("getConfig", func() {
|
||||
conf.Server.DevAutoCreateAdminPassword = "adminpassword123"
|
||||
conf.Server.Prometheus.Password = "prometheuspass"
|
||||
|
||||
req := httptest.NewRequest("GET", "/config", nil)
|
||||
req := createAuthenticatedConfigRequest(adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
|
||||
getConfig(w, req.WithContext(ctx))
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var resp configResponse
|
||||
@@ -88,10 +119,10 @@ var _ = Describe("getConfig", func() {
|
||||
conf.Server.LastFM.ApiKey = ""
|
||||
conf.Server.PasswordEncryptionKey = ""
|
||||
|
||||
req := httptest.NewRequest("GET", "/config", nil)
|
||||
req := createAuthenticatedConfigRequest(adminToken)
|
||||
w := httptest.NewRecorder()
|
||||
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
|
||||
getConfig(w, req.WithContext(ctx))
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var resp configResponse
|
||||
@@ -106,6 +137,37 @@ var _ = Describe("getConfig", func() {
|
||||
Expect(resp.Config["PasswordEncryptionKey"]).To(Equal(""))
|
||||
})
|
||||
})
|
||||
|
||||
Context("as regular user", func() {
|
||||
var userToken string
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
userToken, err = auth.CreateToken(®ularUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("denies access with forbidden status", func() {
|
||||
req := createAuthenticatedConfigRequest(userToken)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
})
|
||||
|
||||
Context("without authentication", func() {
|
||||
It("denies access with unauthorized status", func() {
|
||||
req := createUnauthenticatedConfigRequest("GET", "/config/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("redactValue function", func() {
|
||||
@@ -145,3 +207,21 @@ var _ = Describe("redactValue function", func() {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"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) {
|
||||
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)
|
||||
id, err := p.String("id")
|
||||
|
||||
|
||||
101
server/nativeapi/library.go
Normal file
101
server/nativeapi/library.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
424
server/nativeapi/library_test.go
Normal file
424
server/nativeapi/library_test.go
Normal 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(®ularUser)).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(®ularUser)
|
||||
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(®ularUser)
|
||||
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
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
)
|
||||
|
||||
@@ -25,10 +26,11 @@ type Router struct {
|
||||
share core.Share
|
||||
playlists core.Playlists
|
||||
insights metrics.Insights
|
||||
libs core.Library
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights) *Router {
|
||||
r := &Router{ds: ds, share: share, playlists: playlists, insights: insights}
|
||||
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, libs: libraryService}
|
||||
r.Handler = r.routes()
|
||||
return r
|
||||
}
|
||||
@@ -62,10 +64,15 @@ func (n *Router) routes() http.Handler {
|
||||
n.addSongPlaylistsRoute(r)
|
||||
n.addQueueRoute(r)
|
||||
n.addMissingFilesRoute(r)
|
||||
n.addInspectRoute(r)
|
||||
n.addConfigRoute(r)
|
||||
n.addKeepAliveRoute(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
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,20 +2,17 @@ package nativeapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"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/auth"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
@@ -23,31 +20,6 @@ import (
|
||||
. "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 (
|
||||
router http.Handler
|
||||
@@ -122,13 +94,8 @@ var _ = Describe("Song Endpoints", func() {
|
||||
}
|
||||
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
|
||||
nativeRouter := New(ds, mockShareImpl, mockPlaylistsImpl, mockInsightsImpl)
|
||||
nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/navidrome/navidrome/server/subsonic/filter"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/navidrome/navidrome/utils/run"
|
||||
"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)
|
||||
}
|
||||
|
||||
// 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.Max = min(p.IntOr("size", 10), 500)
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
artists, err := api.ds.Artist(ctx).GetAll(filter.ArtistsByStarred())
|
||||
|
||||
// Get optional library IDs from musicFolderId parameter
|
||||
musicFolderIds, err := selectedMusicFolderIds(r, false)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
// 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 nil, err
|
||||
}
|
||||
options := filter.ByStarred()
|
||||
albums, err := api.ds.Album(ctx).GetAll(options)
|
||||
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 nil, err
|
||||
}
|
||||
mediaFiles, err := api.ds.MediaFile(ctx).GetAll(options)
|
||||
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 {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.Starred = &responses.Starred{}
|
||||
response.Starred.Artist = slice.MapWithArg(artists, r, toArtist)
|
||||
response.Starred.Album = slice.MapWithArg(albums, ctx, childFromAlbum)
|
||||
response.Starred.Song = slice.MapWithArg(mediaFiles, ctx, childFromMediaFile)
|
||||
response.Starred.Album = slice.MapWithArg(albums, r.Context(), childFromAlbum)
|
||||
response.Starred.Song = slice.MapWithArg(mediaFiles, r.Context(), childFromMediaFile)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetStarred2(r *http.Request) (*responses.Subsonic, error) {
|
||||
ctx := r.Context()
|
||||
artists, err := api.ds.Artist(ctx).GetAll(filter.ArtistsByStarred())
|
||||
artists, albums, mediaFiles, err := api.getStarredItems(r)
|
||||
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
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
response.Starred2 = &responses.Starred2{}
|
||||
response.Starred2.Artist = slice.MapWithArg(artists, r, toArtistID3)
|
||||
response.Starred2.Album = slice.MapWithArg(albums, ctx, buildAlbumID3)
|
||||
response.Starred2.Song = slice.MapWithArg(mediaFiles, ctx, childFromMediaFile)
|
||||
response.Starred2.Album = slice.MapWithArg(albums, r.Context(), buildAlbumID3)
|
||||
response.Starred2.Song = slice.MapWithArg(mediaFiles, r.Context(), childFromMediaFile)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -193,7 +231,15 @@ func (api *Router) GetRandomSongs(r *http.Request) (*responses.Subsonic, error)
|
||||
fromYear := p.IntOr("fromYear", 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 {
|
||||
log.Error(r, "Error retrieving random songs", err)
|
||||
return nil, err
|
||||
@@ -211,8 +257,16 @@ func (api *Router) GetSongsByGenre(r *http.Request) (*responses.Subsonic, error)
|
||||
offset := p.IntOr("offset", 0)
|
||||
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()
|
||||
songs, err := api.getSongs(ctx, offset, count, filter.ByGenre(genre))
|
||||
songs, err := api.getSongs(ctx, offset, count, opts)
|
||||
if err != nil {
|
||||
log.Error(r, "Error retrieving random songs", err)
|
||||
return nil, err
|
||||
|
||||
@@ -5,12 +5,13 @@ import (
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"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/utils/req"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -24,6 +25,7 @@ var _ = Describe("Album Lists", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
w = httptest.NewRecorder()
|
||||
@@ -63,6 +65,74 @@ var _ = Describe("Album Lists", func() {
|
||||
errors.As(err, &subErr)
|
||||
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() {
|
||||
@@ -100,5 +170,373 @@ var _ = Describe("Album Lists", func() {
|
||||
errors.As(err, &subErr)
|
||||
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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/public"
|
||||
@@ -17,7 +18,8 @@ import (
|
||||
)
|
||||
|
||||
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))
|
||||
for i, f := range libraries {
|
||||
folders[i].Id = int32(f.ID)
|
||||
@@ -28,28 +30,37 @@ func (api *Router) GetMusicFolders(r *http.Request) (*responses.Subsonic, error)
|
||||
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()
|
||||
lib, err := api.ds.Library(ctx).Get(libId)
|
||||
|
||||
lastScanStr, err := api.ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "")
|
||||
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
|
||||
}
|
||||
lastScan := time.Now()
|
||||
if lastScanStr != "" {
|
||||
lastScan, err = time.Parse(time.RFC3339, lastScanStr)
|
||||
}
|
||||
|
||||
var indexes model.ArtistIndexes
|
||||
if lib.LastScanAt.After(ifModifiedSince) {
|
||||
indexes, err = api.ds.Artist(ctx).GetIndex(false, model.RoleAlbumArtist)
|
||||
if lastScan.After(ifModifiedSince) {
|
||||
indexes, err = api.ds.Artist(ctx).GetIndex(false, libIds, model.RoleAlbumArtist)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error retrieving Indexes", 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) {
|
||||
indexes, modified, err := api.getArtist(r, libId, ifModifiedSince)
|
||||
func (api *Router) getArtistIndex(r *http.Request, libIds []int, ifModifiedSince time.Time) (*responses.Indexes, error) {
|
||||
indexes, modified, err := api.getArtist(r, libIds, ifModifiedSince)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -67,8 +78,8 @@ func (api *Router) getArtistIndex(r *http.Request, libId int, ifModifiedSince ti
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (api *Router) getArtistIndexID3(r *http.Request, libId int, ifModifiedSince time.Time) (*responses.Artists, error) {
|
||||
indexes, modified, err := api.getArtist(r, libId, ifModifiedSince)
|
||||
func (api *Router) getArtistIndexID3(r *http.Request, libIds []int, ifModifiedSince time.Time) (*responses.Artists, error) {
|
||||
indexes, modified, err := api.getArtist(r, libIds, ifModifiedSince)
|
||||
if err != nil {
|
||||
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) {
|
||||
p := req.Params(r)
|
||||
musicFolderId := p.IntOr("musicFolderId", 1)
|
||||
musicFolderIds, _ := selectedMusicFolderIds(r, false)
|
||||
ifModifiedSince := p.TimeOr("ifModifiedSince", time.Time{})
|
||||
|
||||
res, err := api.getArtistIndex(r, musicFolderId, ifModifiedSince)
|
||||
res, err := api.getArtistIndex(r, musicFolderIds, ifModifiedSince)
|
||||
if err != nil {
|
||||
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) {
|
||||
p := req.Params(r)
|
||||
musicFolderId := p.IntOr("musicFolderId", 1)
|
||||
res, err := api.getArtistIndexID3(r, musicFolderId, time.Time{})
|
||||
musicFolderIds, _ := selectedMusicFolderIds(r, false)
|
||||
|
||||
res, err := api.getArtistIndexID3(r, musicFolderIds, time.Time{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
160
server/subsonic/browsing_test.go
Normal file
160
server/subsonic/browsing_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
return addDefaultFilters(Options{
|
||||
Sort: "name",
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"github.com/navidrome/navidrome/server/public"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/number"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
@@ -474,3 +476,40 @@ func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList) *responses
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -163,4 +168,108 @@ var _ = Describe("helpers", func() {
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -138,4 +138,8 @@ func (f *fakeEventBroker) SendMessage(_ context.Context, event 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)
|
||||
|
||||
@@ -76,7 +76,7 @@ func (api *Router) create(ctx context.Context, playlistId, name string, ids []st
|
||||
pls.OwnerID = owner.ID
|
||||
}
|
||||
pls.Tracks = nil
|
||||
pls.AddTracks(ids)
|
||||
pls.AddMediaFilesByID(ids)
|
||||
|
||||
err = tx.Playlist(ctx).Put(pls)
|
||||
playlistId = pls.ID
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/sanitize"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -41,9 +42,9 @@ func (api *Router) getSearchParams(r *http.Request) (*searchParams, error) {
|
||||
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 {
|
||||
if size == 0 {
|
||||
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.")
|
||||
var err error
|
||||
start := time.Now()
|
||||
*result, err = s(q, offset, size, false)
|
||||
*result, err = s(q, offset, size, false, options...)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error searching "+typ, "query", q, "elapsed", time.Since(start), err)
|
||||
} 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()
|
||||
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
|
||||
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.Album(ctx).Search, q, sp.albumOffset, sp.albumCount, &albums))
|
||||
g.Go(callSearch(ctx, api.ds.Artist(ctx).Search, q, sp.artistOffset, sp.artistCount, &artists))
|
||||
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, options...))
|
||||
g.Go(callSearch(ctx, api.ds.Artist(ctx).Search, q, sp.artistOffset, sp.artistCount, &artists, options...))
|
||||
err := g.Wait()
|
||||
if err == nil {
|
||||
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 {
|
||||
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()
|
||||
searchResult2 := &responses.SearchResult2{}
|
||||
@@ -115,7 +130,13 @@ func (api *Router) Search3(r *http.Request) (*responses.Subsonic, error) {
|
||||
if err != nil {
|
||||
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()
|
||||
searchResult3 := &responses.SearchResult3{}
|
||||
|
||||
208
server/subsonic/searching_test.go
Normal file
208
server/subsonic/searching_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,7 @@ type MockAlbumRepo struct {
|
||||
All model.Albums
|
||||
Err bool
|
||||
Options model.QueryOptions
|
||||
ReassignAnnotationCalls map[string]string // prevID -> newID
|
||||
}
|
||||
|
||||
func (m *MockAlbumRepo) SetError(err bool) {
|
||||
@@ -117,4 +118,44 @@ func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error {
|
||||
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)
|
||||
|
||||
@@ -18,6 +18,7 @@ type MockArtistRepo struct {
|
||||
model.ArtistRepository
|
||||
Data map[string]*model.Artist
|
||||
Err bool
|
||||
Options model.QueryOptions
|
||||
}
|
||||
|
||||
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) {
|
||||
if len(options) > 0 {
|
||||
m.Options = options[0]
|
||||
}
|
||||
if m.Err {
|
||||
return nil, errors.New("mock repo error")
|
||||
}
|
||||
@@ -108,4 +112,49 @@ func (m *MockArtistRepo) RefreshPlayCounts() (int64, error) {
|
||||
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)
|
||||
|
||||
@@ -27,6 +27,7 @@ type MockDataStore struct {
|
||||
MockedScrobbleBuffer model.ScrobbleBufferRepository
|
||||
MockedRadio model.RadioRepository
|
||||
scrobbleBufferMu sync.Mutex
|
||||
repoMu sync.Mutex
|
||||
}
|
||||
|
||||
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 {
|
||||
db.repoMu.Lock()
|
||||
defer db.repoMu.Unlock()
|
||||
if db.MockedMediaFile == nil {
|
||||
if db.RealDS != nil {
|
||||
db.MockedMediaFile = db.RealDS.MediaFile(ctx)
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"golang.org/x/exp/maps"
|
||||
)
|
||||
|
||||
type MockLibraryRepo struct {
|
||||
model.LibraryRepository
|
||||
Data map[int]model.Library
|
||||
Err error
|
||||
PutFn func(*model.Library) error // Allow custom Put behavior for testing
|
||||
}
|
||||
|
||||
func (m *MockLibraryRepo) SetData(data model.Libraries) {
|
||||
@@ -22,7 +30,54 @@ func (m *MockLibraryRepo) GetAll(...model.QueryOptions) (model.Libraries, error)
|
||||
if m.Err != nil {
|
||||
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) {
|
||||
@@ -35,8 +90,223 @@ func (m *MockLibraryRepo) GetPath(id int) (string, error) {
|
||||
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 {
|
||||
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)
|
||||
|
||||
@@ -27,6 +27,10 @@ type MockMediaFileRepo struct {
|
||||
CountAllValue int64
|
||||
CountAllOptions model.QueryOptions
|
||||
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) {
|
||||
@@ -72,7 +76,10 @@ func (m *MockMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, er
|
||||
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 {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
@@ -227,5 +234,66 @@ func (m *MockMediaFileRepo) NewInstance() interface{} {
|
||||
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.ResourceRepository = (*MockMediaFileRepo)(nil)
|
||||
|
||||
@@ -2,6 +2,7 @@ package tests
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
func CreateMockUserRepo() *MockedUserRepo {
|
||||
return &MockedUserRepo{
|
||||
Data: map[string]*model.User{},
|
||||
UserLibraries: map[string][]int{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +21,7 @@ type MockedUserRepo struct {
|
||||
model.UserRepository
|
||||
Error error
|
||||
Data map[string]*model.User
|
||||
UserLibraries map[string][]int // userID -> libraryIDs
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
for _, usr := range u.Data {
|
||||
if usr.ID == id {
|
||||
@@ -74,3 +89,37 @@ func (u *MockedUserRepo) UpdateLastAccessAt(id string) 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
|
||||
}
|
||||
|
||||
@@ -15,9 +15,11 @@ import artist from './artist'
|
||||
import playlist from './playlist'
|
||||
import radio from './radio'
|
||||
import share from './share'
|
||||
import library from './library'
|
||||
import { Player } from './audioplayer'
|
||||
import customRoutes from './routes'
|
||||
import {
|
||||
libraryReducer,
|
||||
themeReducer,
|
||||
addToPlaylistDialogReducer,
|
||||
expandInfoDialogReducer,
|
||||
@@ -56,6 +58,7 @@ const adminStore = createAdminStore({
|
||||
dataProvider,
|
||||
history,
|
||||
customReducers: {
|
||||
library: libraryReducer,
|
||||
player: playerReducer,
|
||||
albumView: albumViewReducer,
|
||||
theme: themeReducer,
|
||||
@@ -122,7 +125,13 @@ const Admin = (props) => {
|
||||
) : (
|
||||
<Resource name="transcoding" />
|
||||
),
|
||||
|
||||
permissions === 'admin' ? (
|
||||
<Resource
|
||||
name="library"
|
||||
{...library}
|
||||
options={{ subMenu: 'settings' }}
|
||||
/>
|
||||
) : null,
|
||||
permissions === 'admin' ? (
|
||||
<Resource
|
||||
name="missing"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './library'
|
||||
export * from './player'
|
||||
export * from './themes'
|
||||
export * from './albumView'
|
||||
|
||||
12
ui/src/actions/library.js
Normal file
12
ui/src/actions/library.js
Normal 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,
|
||||
})
|
||||
@@ -38,6 +38,7 @@ const AlbumInfo = (props) => {
|
||||
const record = useRecordContext(props)
|
||||
const data = {
|
||||
album: <TextField source={'name'} />,
|
||||
libraryName: <TextField source="libraryName" />,
|
||||
albumArtist: (
|
||||
<ArtistLinkField source="albumArtist" record={record} limit={Infinity} />
|
||||
),
|
||||
|
||||
221
ui/src/common/LibrarySelector.jsx
Normal file
221
ui/src/common/LibrarySelector.jsx
Normal 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
|
||||
517
ui/src/common/LibrarySelector.test.jsx
Normal file
517
ui/src/common/LibrarySelector.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
228
ui/src/common/SelectLibraryInput.jsx
Normal file
228
ui/src/common/SelectLibraryInput.jsx
Normal 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,
|
||||
}
|
||||
458
ui/src/common/SelectLibraryInput.test.jsx
Normal file
458
ui/src/common/SelectLibraryInput.test.jsx
Normal 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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -59,6 +59,7 @@ export const SongInfo = (props) => {
|
||||
]
|
||||
const data = {
|
||||
path: <PathField />,
|
||||
libraryName: <TextField source="libraryName" />,
|
||||
album: (
|
||||
<AlbumLinkField source="album" sortByOrder={'ASC'} record={record} />
|
||||
),
|
||||
|
||||
@@ -27,6 +27,7 @@ export * from './useAlbumsPerPage'
|
||||
export * from './useGetHandleArtistClick'
|
||||
export * from './useInterval'
|
||||
export * from './useResourceRefresh'
|
||||
export * from './useRefreshOnEvents'
|
||||
export * from './useToggleLove'
|
||||
export * from './useTraceUpdate'
|
||||
export * from './Writable'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user